Compare commits
50 Commits
v1.0.0-rc.
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 698141f70b | |||
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 | |||
| e3f7344daf | |||
| 21a7b0ade2 | |||
| d027fbeac2 | |||
| 8a751998eb | |||
| 48c3e1d013 | |||
| 3df4b44b8d | |||
| 07e0f237b9 | |||
| 57a3944301 | |||
| 5fe0a8d83e | |||
| 8062d33f35 | |||
| ebe67afd73 | |||
| b74f6b6f9e | |||
| 24eea9a2fe | |||
| c70288bba7 | |||
| d438dbdc9d | |||
| e0c4aaf298 | |||
| f44487eeac | |||
| a82b95a28f | |||
| ab688a3dab | |||
| a0ce37708e | |||
| 0379d1f05d | |||
| 50347d049d | |||
| 9678181927 | |||
| 3ffaafefe5 | |||
| e5bf8c861c | |||
| 651e14d665 | |||
| 580cd6789c | |||
| db4cf354ff | |||
| e8957e0672 | |||
| 7ef0bca9f6 | |||
| 198944649a | |||
| 6aa741ab0a | |||
| f69952a5da | |||
| 81af9bf3dd | |||
| f1b617e967 | |||
| d6be9beebf | |||
| 0a797260e3 | |||
| 2a4cc76292 | |||
| f87eb27f41 | |||
| acd86099e5 | |||
| 5ab9791c72 | |||
| 8152ccd5df | |||
| 8eeb571c2d | |||
| b1854d5255 | |||
| 7f4f970a38 | |||
| e5908c757c |
9
.env
9
.env
@@ -1,7 +1,7 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
@@ -28,4 +28,9 @@ DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
|||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
DIRECTUS_DB_NAME=directus
|
DIRECTUS_DB_NAME=directus
|
||||||
DIRECTUS_DB_USER=directus
|
DIRECTUS_DB_USER=directus
|
||||||
DIRECTUS_DB_PASSWORD=directus
|
# Local Development
|
||||||
|
PROJECT_NAME=klz-cables
|
||||||
|
TRAEFIK_HOST=klz.localhost
|
||||||
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
COOKIE_DOMAIN=localhost
|
||||||
|
|||||||
12
.env.example
12
.env.example
@@ -10,13 +10,18 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
|
NEXT_PUBLIC_TARGET=development
|
||||||
|
# TARGET is used server-side
|
||||||
|
TARGET=development
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# Optional: Leave empty to disable analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
@@ -52,6 +57,11 @@ IMAGE_TAG=latest
|
|||||||
TRAEFIK_HOST=klz-cables.com
|
TRAEFIK_HOST=klz-cables.com
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Varnish Configuration
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
VARNISH_CACHE_SIZE=256M
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IMPORTANT NOTES
|
# IMPORTANT NOTES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
|||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ on:
|
|||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -38,11 +38,21 @@ jobs:
|
|||||||
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "Purging old build layers and dangling images..."
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f --filter "until=6h"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 2
|
||||||
|
|
||||||
|
|
||||||
- name: 🔍 Environment & Version ermitteln
|
- name: 🔍 Environment & Version ermitteln
|
||||||
id: determine
|
id: determine
|
||||||
@@ -62,10 +72,10 @@ jobs:
|
|||||||
TARGET="testing"
|
TARGET="testing"
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.testing"
|
ENV_FILE=".env.testing"
|
||||||
TRAEFIK_HOST='`testing.klz-cables.com`'
|
TRAEFIK_HOST="testing.klz-cables.com"
|
||||||
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
||||||
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
||||||
DIRECTUS_HOST='`cms.testing.klz-cables.com`'
|
DIRECTUS_HOST="cms.testing.klz-cables.com"
|
||||||
PROJECT_NAME="klz-cables-testing"
|
PROJECT_NAME="klz-cables-testing"
|
||||||
IS_PROD="false"
|
IS_PROD="false"
|
||||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
GOTIFY_TITLE="🧪 Testing-Deploy"
|
||||||
@@ -76,10 +86,10 @@ jobs:
|
|||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$TAG"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
TRAEFIK_HOST='`klz-cables.com`, `www.klz-cables.com`'
|
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
|
||||||
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
||||||
DIRECTUS_URL="https://cms.klz-cables.com"
|
DIRECTUS_URL="https://cms.klz-cables.com"
|
||||||
DIRECTUS_HOST='`cms.klz-cables.com`'
|
DIRECTUS_HOST="cms.klz-cables.com"
|
||||||
PROJECT_NAME="klz-cables-prod"
|
PROJECT_NAME="klz-cables-prod"
|
||||||
IS_PROD="true"
|
IS_PROD="true"
|
||||||
GOTIFY_TITLE="🚀 Production-Release"
|
GOTIFY_TITLE="🚀 Production-Release"
|
||||||
@@ -88,10 +98,10 @@ jobs:
|
|||||||
TARGET="staging"
|
TARGET="staging"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$TAG"
|
||||||
ENV_FILE=".env.staging"
|
ENV_FILE=".env.staging"
|
||||||
TRAEFIK_HOST='`staging.klz-cables.com`'
|
TRAEFIK_HOST="staging.klz-cables.com"
|
||||||
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
||||||
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
||||||
DIRECTUS_HOST='`cms.staging.klz-cables.com`'
|
DIRECTUS_HOST="cms.staging.klz-cables.com"
|
||||||
PROJECT_NAME="klz-cables-staging"
|
PROJECT_NAME="klz-cables-staging"
|
||||||
IS_PROD="false"
|
IS_PROD="false"
|
||||||
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
||||||
@@ -105,19 +115,21 @@ jobs:
|
|||||||
TARGET="skip"
|
TARGET="skip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "target=$TARGET" >> $GITHUB_OUTPUT
|
{
|
||||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
echo "target=$TARGET"
|
||||||
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
|
echo "env_file=$ENV_FILE"
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
|
echo "traefik_host=$TRAEFIK_HOST"
|
||||||
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
|
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||||
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
|
echo "directus_url=$DIRECTUS_URL"
|
||||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
echo "directus_host=$DIRECTUS_HOST"
|
||||||
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
|
echo "project_name=$PROJECT_NAME"
|
||||||
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
|
echo "is_prod=$IS_PROD"
|
||||||
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
|
echo "gotify_title=$GOTIFY_TITLE"
|
||||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
echo "gotify_priority=$GOTIFY_PRIORITY"
|
||||||
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
echo "commit_msg=$COMMIT_MSG"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 2: Quality Assurance (Lint & Test)
|
# JOB 2: Quality Assurance (Lint & Test)
|
||||||
@@ -127,26 +139,21 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: 📦 Restore npm cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-node-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: 🧪 Run Checks in Parallel
|
- name: 🧪 Run Checks in Parallel
|
||||||
if: github.event.inputs.skip_long_checks != 'true'
|
if: github.event.inputs.skip_long_checks != 'true'
|
||||||
@@ -166,60 +173,58 @@ jobs:
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push Docker Image
|
# JOB 3: Build & Push Docker Image
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
build:
|
build-app:
|
||||||
name: 🏗️ Build & Push
|
name: 🏗️ Build App
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
- name: 🏗️ Docker Image bauen & pushen
|
- name: 🏗️ App bauen & pushen
|
||||||
env:
|
env:
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building → $TARGET / $IMAGE_TAG"
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--pull \
|
--pull \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
--build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
|
||||||
|
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
||||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
|
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
|
||||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
|
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
- name: 🏗️ Gatekeeper bauen & pushen
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--pull \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
|
|
||||||
--push ./gatekeeper
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy via SSH
|
# JOB 4: Deploy via SSH
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
needs: [prepare, build, qa]
|
needs: [prepare, build-app, qa]
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
@@ -227,31 +232,34 @@ jobs:
|
|||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
SENTRY_DSN: ${{ needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
|
||||||
MAIL_HOST: ${{ secrets.MAIL_HOST }}
|
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
||||||
MAIL_PORT: ${{ secrets.MAIL_PORT }}
|
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
||||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
|
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
||||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
|
||||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
|
||||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS }}
|
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY }}
|
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
|
||||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
|
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
|
||||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
|
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
|
||||||
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
|
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
|
||||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
||||||
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }}
|
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
|
||||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
|
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
- name: 🚀 Deploy to ${{ env.TARGET }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||||
|
|
||||||
@@ -264,9 +272,11 @@ jobs:
|
|||||||
# Generated by CI - $TARGET - $(date -u)
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
NEXT_PUBLIC_TARGET=$TARGET
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
MAIL_HOST=$MAIL_HOST
|
MAIL_HOST=$MAIL_HOST
|
||||||
MAIL_PORT=$MAIL_PORT
|
MAIL_PORT=$MAIL_PORT
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
@@ -288,26 +298,45 @@ jobs:
|
|||||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||||
|
|
||||||
|
TARGET=$TARGET
|
||||||
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
ENV_FILE=$ENV_FILE
|
ENV_FILE=$ENV_FILE
|
||||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# 1. Cleanup and Create Directories on server BEFORE SCP
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||||
|
set -e
|
||||||
|
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
||||||
|
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads /home/deploy/sites/klz-cables.com/directus/extensions
|
||||||
|
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
||||||
|
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
||||||
|
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
||||||
|
fi
|
||||||
|
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. Transfer files
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
||||||
|
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||||
set -e
|
set -e
|
||||||
cd /home/deploy/sites/klz-cables.com
|
cd /home/deploy/sites/klz-cables.com
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
chown deploy:deploy "$ENV_FILE"
|
chown deploy:deploy "$ENV_FILE"
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
echo "→ Pulling image: $IMAGE_TAG"
|
echo "→ Pulling image: $IMAGE_TAG"
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||||
echo "→ Starting containers..."
|
echo "→ Starting containers..."
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||||
docker system prune -f --filter "until=168h"
|
docker system prune -f --filter "until=24h"
|
||||||
echo "→ Waiting 15s for warmup..."
|
echo "→ Waiting 15s for warmup..."
|
||||||
sleep 15
|
sleep 15
|
||||||
echo "→ Container status:"
|
echo "→ Container status:"
|
||||||
@@ -317,6 +346,15 @@ jobs:
|
|||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "→ Verifying Varnish Backend Health..."
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
||||||
|
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
||||||
|
echo "❌ Fehler: Varnish Backend ist SICK!"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Varnish Backend ist Healthy."
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 5: PageSpeed Test
|
# JOB 5: PageSpeed Test
|
||||||
@@ -330,20 +368,23 @@ jobs:
|
|||||||
needs.deploy.result == 'success' &&
|
needs.deploy.result == 'success' &&
|
||||||
github.event.inputs.skip_long_checks != 'true'
|
github.event.inputs.skip_long_checks != 'true'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
outputs:
|
container:
|
||||||
report_url: ${{ steps.save.outputs.report_url }}
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
# outputs:
|
||||||
|
# report_url: ${{ steps.save.outputs.report_url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
run: |
|
run: |
|
||||||
@@ -412,24 +453,18 @@ jobs:
|
|||||||
CHROME_PATH: /usr/bin/chromium
|
CHROME_PATH: /usr/bin/chromium
|
||||||
run: npm run pagespeed:test
|
run: npm run pagespeed:test
|
||||||
|
|
||||||
- name: 💾 Save Report URL
|
|
||||||
id: save
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -f pagespeed-report-url.txt ]; then
|
|
||||||
URL=$(cat pagespeed-report-url.txt)
|
|
||||||
echo "report_url=$URL" >> $GITHUB_OUTPUT
|
|
||||||
echo "✅ Report URL found: $URL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 6: Notifications
|
# JOB 6: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notifications
|
name: 🔔 Notifications
|
||||||
needs: [prepare, qa, build, deploy, pagespeed]
|
needs: [prepare, qa, build-app, deploy, pagespeed]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 📊 Deployment Summary
|
- name: 📊 Deployment Summary
|
||||||
run: |
|
run: |
|
||||||
@@ -446,18 +481,18 @@ jobs:
|
|||||||
- name: 🔔 Gotify - Success
|
- name: 🔔 Gotify - Success
|
||||||
if: needs.deploy.result == 'success'
|
if: needs.deploy.result == 'success'
|
||||||
run: |
|
run: |
|
||||||
REPORT_MSG=""
|
|
||||||
if [ -n "${{ needs.pagespeed.outputs.report_url }}" ]; then
|
|
||||||
REPORT_MSG="\n\n⚡ **PageSpeed Report:**\n${{ needs.pagespeed.outputs.report_url }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}${REPORT_MSG}" \
|
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
||||||
-F "priority=4" || true
|
-F "priority=4" || true
|
||||||
|
|
||||||
- name: 🔔 Gotify - Failure
|
- name: 🔔 Gotify - Failure
|
||||||
if: needs.deploy.result == 'failure' || needs.build.result == 'failure' || needs.qa.result == 'failure'
|
if: |
|
||||||
|
needs.prepare.result == 'failure' ||
|
||||||
|
needs.qa.result == 'failure' ||
|
||||||
|
needs.build-app.result == 'failure' ||
|
||||||
|
needs.deploy.result == 'failure' ||
|
||||||
|
needs.pagespeed.result == 'failure'
|
||||||
run: |
|
run: |
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -8,7 +8,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
@@ -25,17 +25,22 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# Build-time environment variables for Next.js
|
# Build-time environment variables for Next.js
|
||||||
# These are baked into the client bundle during build
|
# These are baked into the client bundle during build
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
ARG UMAMI_SCRIPT_URL
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}}
|
||||||
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
|
||||||
# Validate environment variables during build
|
# Validate environment variables during build
|
||||||
RUN npx tsx scripts/validate-env.ts
|
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
|
||||||
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
```bash
|
|
||||||
|
````bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
@@ -42,11 +44,12 @@ npm run cms:logs
|
|||||||
|
|
||||||
# Stop the CMS
|
# Stop the CMS
|
||||||
npm run cms:stop
|
npm run cms:stop
|
||||||
```
|
````
|
||||||
|
|
||||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||||
|
|
||||||
### 🔄 Data & Migration
|
### 🔄 Data & Migration
|
||||||
|
|
||||||
To sync data or migrate existing content:
|
To sync data or migrate existing content:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,6 +64,7 @@ npm run cms:migrate
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
SITE_URL=https://klz-cables.com
|
SITE_URL=https://klz-cables.com
|
||||||
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
|||||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||||
|
|
||||||
# Umami
|
# Umami
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# GlitchTip (Sentry compatible)
|
# GlitchTip (Sentry compatible)
|
||||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||||
@@ -81,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 📊 Project Overview
|
## 📊 Project Overview
|
||||||
|
|
||||||
### Migration Statistics
|
### Migration Statistics
|
||||||
|
|
||||||
- **Content Exported**: 141 items
|
- **Content Exported**: 141 items
|
||||||
- 18 pages (9 EN + 9 DE)
|
- 18 pages (9 EN + 9 DE)
|
||||||
- 59 posts (29 EN + 30 DE)
|
- 59 posts (29 EN + 30 DE)
|
||||||
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **Translation Pairs**: 16
|
- **Translation Pairs**: 16
|
||||||
|
|
||||||
### Performance Benefits
|
### Performance Benefits
|
||||||
|
|
||||||
- **Before**: Dynamic WordPress with database queries
|
- **Before**: Dynamic WordPress with database queries
|
||||||
- **After**: Static HTML with CDN delivery
|
- **After**: Static HTML with CDN delivery
|
||||||
- **Load Time**: <100ms (vs 500ms+)
|
- **Load Time**: <100ms (vs 500ms+)
|
||||||
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 14 (App Router)
|
- **Framework**: Next.js 14 (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Styling**: SCSS
|
- **Styling**: SCSS
|
||||||
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
|||||||
- **CAPTCHA**: Cloudflare Turnstile
|
- **CAPTCHA**: Cloudflare Turnstile
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
@@ -163,6 +171,7 @@ scripts/
|
|||||||
## 🎯 Features
|
## 🎯 Features
|
||||||
|
|
||||||
### ✅ Implemented
|
### ✅ Implemented
|
||||||
|
|
||||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||||
- **Contact Forms**: Resend integration with validation
|
- **Contact Forms**: Resend integration with validation
|
||||||
- **GDPR Compliance**: Cookie consent banner
|
- **GDPR Compliance**: Cookie consent banner
|
||||||
@@ -175,12 +184,14 @@ scripts/
|
|||||||
- **Asset Management**: WordPress → local path mapping
|
- **Asset Management**: WordPress → local path mapping
|
||||||
|
|
||||||
### 🔄 In Progress
|
### 🔄 In Progress
|
||||||
|
|
||||||
- Analytics integration (consent-based)
|
- Analytics integration (consent-based)
|
||||||
- Turnstile CAPTCHA
|
- Turnstile CAPTCHA
|
||||||
- Build testing
|
- Build testing
|
||||||
- Deployment configuration
|
- Deployment configuration
|
||||||
|
|
||||||
### 📝 Remaining
|
### 📝 Remaining
|
||||||
|
|
||||||
- Performance optimization
|
- Performance optimization
|
||||||
- Final QA testing
|
- Final QA testing
|
||||||
- Documentation updates
|
- Documentation updates
|
||||||
@@ -188,6 +199,7 @@ scripts/
|
|||||||
## 📝 Content Management
|
## 📝 Content Management
|
||||||
|
|
||||||
### Data Export
|
### Data Export
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Export from WordPress
|
# Export from WordPress
|
||||||
npm run data:export
|
npm run data:export
|
||||||
@@ -203,6 +215,7 @@ npm run data:improve-mapping
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Content
|
### Adding New Content
|
||||||
|
|
||||||
1. Export new content from WordPress
|
1. Export new content from WordPress
|
||||||
2. Process the data
|
2. Process the data
|
||||||
3. Rebuild the site
|
3. Rebuild the site
|
||||||
@@ -210,17 +223,20 @@ npm run data:improve-mapping
|
|||||||
## 🎨 Design System
|
## 🎨 Design System
|
||||||
|
|
||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- Primary: `#0066cc` (KLZ Blue)
|
- Primary: `#0066cc` (KLZ Blue)
|
||||||
- Secondary: `#00a896` (Teal)
|
- Secondary: `#00a896` (Teal)
|
||||||
- Text: `#1a1a1a`
|
- Text: `#1a1a1a`
|
||||||
- Background: `#f8f9fa`
|
- Background: `#f8f9fa`
|
||||||
|
|
||||||
### Typography
|
### Typography
|
||||||
|
|
||||||
- Font: Inter
|
- Font: Inter
|
||||||
- Base: 16px
|
- Base: 16px
|
||||||
- Scale: 1.25 (Major Third)
|
- Scale: 1.25 (Major Third)
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
- Max width: 1200px
|
- Max width: 1200px
|
||||||
- Responsive grid
|
- Responsive grid
|
||||||
- Mobile-first
|
- Mobile-first
|
||||||
@@ -228,6 +244,7 @@ npm run data:improve-mapping
|
|||||||
## 🔧 API Endpoints
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
### Contact Form
|
### Contact Form
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/contact
|
POST /api/contact
|
||||||
{
|
{
|
||||||
@@ -239,11 +256,13 @@ POST /api/contact
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sitemap
|
### Sitemap
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /sitemap.xml
|
GET /sitemap.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robots
|
### Robots
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /robots.txt
|
GET /robots.txt
|
||||||
```
|
```
|
||||||
@@ -261,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
**Branch Deployments**:
|
**Branch Deployments**:
|
||||||
|
|
||||||
- `main` branch: Deploys to production using `.env.prod`
|
- `main` branch: Deploys to production using `.env.prod`
|
||||||
- `staging` branch: Deploys to staging using `.env.staging`
|
- `staging` branch: Deploys to staging using `.env.staging`
|
||||||
|
|
||||||
@@ -268,12 +288,13 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
|
|||||||
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||||
|
|
||||||
**Required Secrets** (configure in Gitea repository settings):
|
**Required Secrets** (configure in Gitea repository settings):
|
||||||
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
- `REGISTRY_USER` - Docker registry username
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
- `REGISTRY_PASS` - Docker registry password
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||||
|
|
||||||
@@ -293,6 +314,7 @@ docker image prune -f
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or use the convenience script:
|
Or use the convenience script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/deploy-webhook.sh
|
bash scripts/deploy-webhook.sh
|
||||||
```
|
```
|
||||||
@@ -304,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Domains**:
|
**Domains**:
|
||||||
|
|
||||||
- `klz-cables.com` - Production
|
- `klz-cables.com` - Production
|
||||||
- `www.klz-cables.com` - Production (www)
|
- `www.klz-cables.com` - Production (www)
|
||||||
- `staging.klz-cables.com` - Staging
|
- `staging.klz-cables.com` - Staging
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
- `app`: Next.js application (port 3000)
|
- `app`: Next.js application (port 3000)
|
||||||
- `traefik`: Reverse proxy (external)
|
- `traefik`: Reverse proxy (external)
|
||||||
|
|
||||||
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 📈 Performance
|
## 📈 Performance
|
||||||
|
|
||||||
### Build Time
|
### Build Time
|
||||||
|
|
||||||
- **Target**: < 2 minutes
|
- **Target**: < 2 minutes
|
||||||
- **Current**: ~1-2 minutes
|
- **Current**: ~1-2 minutes
|
||||||
|
|
||||||
### Page Load
|
### Page Load
|
||||||
|
|
||||||
- **Target**: < 100ms
|
- **Target**: < 100ms
|
||||||
- **Current**: Static HTML from CDN
|
- **Current**: Static HTML from CDN
|
||||||
|
|
||||||
### Bundle Size
|
### Bundle Size
|
||||||
|
|
||||||
- **Target**: < 100KB gzipped
|
- **Target**: < 100KB gzipped
|
||||||
- **Current**: Optimized with code splitting
|
- **Current**: Optimized with code splitting
|
||||||
|
|
||||||
## 🔒 Security
|
## 🔒 Security
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Never commit `.env` file
|
- Never commit `.env` file
|
||||||
- Rotate keys regularly
|
- Rotate keys regularly
|
||||||
- Use secrets in deployment platform
|
- Use secrets in deployment platform
|
||||||
|
|
||||||
### Form Security
|
### Form Security
|
||||||
|
|
||||||
- Email validation
|
- Email validation
|
||||||
- Rate limiting (recommended)
|
- Rate limiting (recommended)
|
||||||
- Turnstile CAPTCHA (pending)
|
- Turnstile CAPTCHA (pending)
|
||||||
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
## 🎓 WordPress Specifics
|
## 🎓 WordPress Specifics
|
||||||
|
|
||||||
### WPBakery Shortcodes Removed
|
### WPBakery Shortcodes Removed
|
||||||
|
|
||||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||||
- `[nectar_*]` (Salient theme)
|
- `[nectar_*]` (Salient theme)
|
||||||
- `[image_with_animation]`
|
- `[image_with_animation]`
|
||||||
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
|||||||
- `[divider]`
|
- `[divider]`
|
||||||
|
|
||||||
### HTML Sanitization
|
### HTML Sanitization
|
||||||
|
|
||||||
- Removes inline event handlers
|
- Removes inline event handlers
|
||||||
- Strips scripts
|
- Strips scripts
|
||||||
- Normalizes classes
|
- Normalizes classes
|
||||||
- Preserves structure
|
- Preserves structure
|
||||||
|
|
||||||
### Asset Mapping
|
### Asset Mapping
|
||||||
|
|
||||||
WordPress URLs → Local paths:
|
WordPress URLs → Local paths:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||||
```
|
```
|
||||||
@@ -364,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||||
- `FINAL_SUMMARY.md` - Complete overview
|
- `FINAL_SUMMARY.md` - Complete overview
|
||||||
|
|
||||||
### External
|
### External
|
||||||
|
|
||||||
- [Next.js Docs](https://nextjs.org/docs)
|
- [Next.js Docs](https://nextjs.org/docs)
|
||||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||||
- [Resend Docs](https://resend.com/docs)
|
- [Resend Docs](https://resend.com/docs)
|
||||||
@@ -379,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
**TypeScript Errors**
|
**TypeScript Errors**
|
||||||
|
|
||||||
- The TypeScript errors shown in the editor are expected
|
- The TypeScript errors shown in the editor are expected
|
||||||
- They occur because modules reference each other
|
- They occur because modules reference each other
|
||||||
- The build process resolves these correctly
|
- The build process resolves these correctly
|
||||||
- Run `npm run build` to verify
|
- Run `npm run build` to verify
|
||||||
|
|
||||||
**Build Failures**
|
**Build Failures**
|
||||||
|
|
||||||
- Check environment variables
|
- Check environment variables
|
||||||
- Verify data files exist
|
- Verify data files exist
|
||||||
- Clear `.next` cache: `rm -rf .next`
|
- Clear `.next` cache: `rm -rf .next`
|
||||||
|
|
||||||
**Missing Modules**
|
**Missing Modules**
|
||||||
|
|
||||||
- Run `npm install --legacy-peer-deps`
|
- Run `npm install --legacy-peer-deps`
|
||||||
- Check `package.json` dependencies
|
- Check `package.json` dependencies
|
||||||
|
|
||||||
@@ -404,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
✅ **i18n**: Multi-language support
|
✅ **i18n**: Multi-language support
|
||||||
✅ **SEO**: Metadata and sitemaps
|
✅ **SEO**: Metadata and sitemaps
|
||||||
✅ **Compatibility**: WPBakery content handled
|
✅ **Compatibility**: WPBakery content handled
|
||||||
✅ **Media**: All images downloaded
|
✅ **Media**: All images downloaded
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
For issues or questions:
|
For issues or questions:
|
||||||
|
|
||||||
1. Check the documentation
|
1. Check the documentation
|
||||||
2. Review the troubleshooting section
|
2. Review the troubleshooting section
|
||||||
3. Check environment variables
|
3. Check environment variables
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
@@ -51,7 +52,7 @@ export default async function LocaleLayout({
|
|||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
{/* Sends pageviews for client-side navigations */}
|
{/* Sends pageviews for client-side navigations */}
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,72 +1,131 @@
|
|||||||
"use server";
|
'use server';
|
||||||
|
|
||||||
import client, { ensureAuthenticated } from "@/lib/directus";
|
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||||
import { createItem } from "@directus/sdk";
|
import { createItem } from '@directus/sdk';
|
||||||
import { sendEmail } from "@/lib/mail/mailer";
|
import { sendEmail } from '@/lib/mail/mailer';
|
||||||
import ContactEmail from "@/components/emails/ContactEmail";
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
export async function sendContactFormAction(formData: FormData) {
|
export async function sendContactFormAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get('name') as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get('email') as string;
|
||||||
const message = formData.get("message") as string;
|
const message = formData.get('message') as string;
|
||||||
const productName = formData.get("productName") as string | null;
|
const productName = formData.get('productName') as string | null;
|
||||||
|
|
||||||
if (!name || !email || !message) {
|
if (!name || !email || !message) {
|
||||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
logger.warn('Missing required fields in contact form', {
|
||||||
return { success: false, error: "Missing required fields" };
|
name: !!name,
|
||||||
|
email: !!email,
|
||||||
|
message: !!message,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Missing required fields' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Save to Directus
|
// 1. Save to Directus
|
||||||
try {
|
try {
|
||||||
await ensureAuthenticated();
|
await ensureAuthenticated();
|
||||||
if (productName) {
|
if (productName) {
|
||||||
await client.request(createItem('product_requests', {
|
await client.request(
|
||||||
product_name: productName,
|
createItem('product_requests', {
|
||||||
email,
|
product_name: productName,
|
||||||
message
|
email,
|
||||||
}));
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.info('Product request stored in Directus');
|
logger.info('Product request stored in Directus');
|
||||||
} else {
|
} else {
|
||||||
await client.request(createItem('contact_submissions', {
|
await client.request(
|
||||||
name,
|
createItem('contact_submissions', {
|
||||||
email,
|
name,
|
||||||
message
|
email,
|
||||||
}));
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.info('Contact submission stored in Directus');
|
logger.info('Contact submission stored in Directus');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to store submission in Directus', { error });
|
logger.error('Failed to store submission in Directus', { error });
|
||||||
// We continue anyway to try sending the email, but maybe we should report this
|
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Send Email
|
// 2. Send Emails
|
||||||
logger.info('Sending contact form email', { email, productName });
|
logger.info('Sending branded emails', { email, productName });
|
||||||
|
|
||||||
const subject = productName
|
const notificationSubject = productName
|
||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: "New Contact Form Submission";
|
: 'New Contact Form Submission';
|
||||||
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
|
||||||
const result = await sendEmail({
|
try {
|
||||||
subject,
|
// 2a. Send notification to Mintel/Client
|
||||||
template: React.createElement(ContactEmail, {
|
const notificationHtml = await render(
|
||||||
name,
|
React.createElement(ContactFormNotification, {
|
||||||
email,
|
name,
|
||||||
message,
|
email,
|
||||||
productName: productName || undefined,
|
message,
|
||||||
subject,
|
productName: productName || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
if (result.success) {
|
const notificationResult = await sendEmail({
|
||||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
replyTo: email,
|
||||||
} else {
|
subject: notificationSubject,
|
||||||
logger.error('Failed to send contact form email', { error: result.error });
|
html: notificationHtml,
|
||||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
});
|
||||||
|
|
||||||
|
if (notificationResult.success) {
|
||||||
|
logger.info('Notification email sent successfully', {
|
||||||
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
|
const confirmationHtml = await render(
|
||||||
|
React.createElement(ConfirmationMessage, {
|
||||||
|
name,
|
||||||
|
clientName: 'KLZ Cables',
|
||||||
|
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmationResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmationResult.success) {
|
||||||
|
logger.info('Confirmation email sent successfully', {
|
||||||
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify via Gotify (Internal)
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: `📩 ${notificationSubject}`,
|
||||||
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to send branded emails', {
|
||||||
|
error: errorMsg,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||||
|
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '🚨 Contact Form Error',
|
||||||
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { getAllProducts } from '@/lib/mdx';
|
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPostsMetadata } from '@/lib/blog';
|
||||||
import { getAllPages } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
|
|
||||||
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
@@ -34,11 +36,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
const products = await getAllProducts(locale);
|
const productsMetadata = await getAllProductsMetadata(locale);
|
||||||
for (const product of products) {
|
for (const product of productsMetadata) {
|
||||||
// We need to find the category for the product to build the URL
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
// In this project, products are under /products/[category]/[slug]
|
|
||||||
// The category is in product.frontmatter.categories
|
|
||||||
const category =
|
const category =
|
||||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
@@ -50,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blog posts
|
// Blog posts
|
||||||
const posts = await getAllPosts(locale);
|
const postsMetadata = await getAllPostsMetadata(locale);
|
||||||
for (const post of posts) {
|
for (const post of postsMetadata) {
|
||||||
|
if (!post.frontmatter || !post.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||||
lastModified: new Date(post.frontmatter.date),
|
lastModified: new Date(post.frontmatter.date),
|
||||||
@@ -61,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const pages = await getAllPages(locale);
|
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||||
for (const page of pages) {
|
for (const page of pagesMetadata) {
|
||||||
|
if (!page.slug) continue;
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
||||||
|
import { config } from '../lib/config';
|
||||||
|
|
||||||
export default function CMSConnectivityNotice() {
|
export default function CMSConnectivityNotice() {
|
||||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||||
@@ -12,14 +13,12 @@ export default function CMSConnectivityNotice() {
|
|||||||
// Only show if we've detected an issue AND we are in a context where we want to see it
|
// Only show if we've detected an issue AND we are in a context where we want to see it
|
||||||
const checkCMS = async () => {
|
const checkCMS = async () => {
|
||||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||||
const isLocal =
|
const isLocal = config.isDevelopment;
|
||||||
window.location.hostname === 'localhost' || window.location.hostname.includes('127.0.0.1');
|
const isTesting = config.isTesting;
|
||||||
const isStaging =
|
|
||||||
window.location.hostname.includes('staging') ||
|
|
||||||
window.location.hostname.includes('testing');
|
|
||||||
|
|
||||||
// Only proceed with check if it's developer context
|
// Only proceed with check if it's developer context (Local or Testing)
|
||||||
if (!isLocal && !isStaging && !isDebug) return;
|
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||||
|
if (!isLocal && !isTesting && !isDebug) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/health/cms');
|
const response = await fetch('/api/health/cms');
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendContactFormAction(formData);
|
const result = await sendContactFormAction(formData);
|
||||||
if (result.success) {
|
if (result?.success) {
|
||||||
trackEvent('contact_form_submission', {
|
trackEvent('contact_form_submission', {
|
||||||
form_type: 'general',
|
form_type: 'general',
|
||||||
email,
|
email,
|
||||||
@@ -41,7 +41,12 @@ export default function ContactForm() {
|
|||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||||
<svg className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
className="w-10 h-10 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +54,8 @@ export default function ContactForm() {
|
|||||||
{t('form.successTitle') || 'Message Sent!'}
|
{t('form.successTitle') || 'Message Sent!'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-text-secondary text-lg mb-8">
|
<p className="text-text-secondary text-lg mb-8">
|
||||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
{t('form.successDesc') ||
|
||||||
|
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||||
{t('form.sendAnother') || 'Send another message'}
|
{t('form.sendAnother') || 'Send another message'}
|
||||||
@@ -62,7 +68,13 @@ export default function ContactForm() {
|
|||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
||||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||||
<svg className="w-10 h-10 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
@@ -74,7 +86,12 @@ export default function ContactForm() {
|
|||||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setStatus('idle')} variant="saturated" size="lg" className="w-full bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive shadow-lg shadow-destructive/20">
|
<Button
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
{t('form.tryAgain') || 'Try Again'}
|
{t('form.tryAgain') || 'Try Again'}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -89,9 +106,9 @@ export default function ContactForm() {
|
|||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="name">{t('form.name')}</Label>
|
<Label htmlFor="name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
@@ -101,9 +118,9 @@ export default function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="email">{t('form.email')}</Label>
|
<Label htmlFor="email">{t('form.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
@@ -114,32 +131,50 @@ export default function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="message">{t('form.message')}</Label>
|
<Label htmlFor="message">{t('form.message')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="saturated"
|
variant="saturated"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-5 w-5 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('form.submitting') || 'Sending...'}
|
{t('form.submitting') || 'Sending...'}
|
||||||
</span>
|
</span>
|
||||||
) : t('form.submit')}
|
) : (
|
||||||
|
t('form.submit')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0">
|
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20">
|
<div className="flex justify-center mb-3">
|
||||||
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<svg
|
||||||
</svg>
|
className="w-5 h-5 text-primary-dark"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
{t('successTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('successDesc', { productName })}
|
{t('successDesc', { productName })}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -73,26 +87,36 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0">
|
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20">
|
<div className="flex justify-center mb-3">
|
||||||
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<svg
|
||||||
<line x1="15" y1="9" x2="9" y2="15" />
|
className="w-5 h-5 text-destructive-foreground"
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
fill="none"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3>
|
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
|
{t('errorTitle') || 'Submission Failed'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setStatus('idle')}
|
onClick={() => setStatus('idle')}
|
||||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-destructive hover:text-destructive/80 transition-colors group"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<span className="border-b-2 border-destructive/10 group-hover:border-destructive/30 transition-colors pb-1">
|
{t('tryAgain') || 'Try Again'}
|
||||||
{t('tryAgain') || 'Try Again'}
|
</Button>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
>
|
>
|
||||||
{status === 'submitting' ? (
|
{status === 'submitting' ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-3 w-3 text-white"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs">{t('submitting')}</span>
|
<span className="text-xs">{t('submitting')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs">{t('submit')}</span>
|
<span className="text-xs">{t('submit')}</span>
|
||||||
<svg className="w-3 h-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-3 h-3 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||||
{t('privacyNote')}
|
{t('privacyNote')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AnalyticsProvider Component
|
* AnalyticsProvider Component
|
||||||
@@ -11,49 +10,35 @@ import Script from 'next/script';
|
|||||||
* Automatically tracks pageviews on client-side route changes.
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
* This component should be placed inside your layout to handle navigation events.
|
* This component should be placed inside your layout to handle navigation events.
|
||||||
*
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // In your layout.tsx
|
* // In your layout.tsx
|
||||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
* const { websiteId } = config.analytics.umami;
|
||||||
* <UmamiScript />
|
* <AnalyticsProvider websiteId={websiteId} />
|
||||||
* <Header />
|
|
||||||
* <main>{children}</main>
|
|
||||||
* <Footer />
|
|
||||||
* <AnalyticsProvider />
|
|
||||||
* </NextIntlClientProvider>
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider() {
|
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathname) return;
|
if (!pathname) return;
|
||||||
|
|
||||||
const services = getAppServices();
|
const services = getAppServices();
|
||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
// Track pageview with the full URL
|
// Track pageview with the full URL
|
||||||
services.analytics.trackPageview(url);
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[Umami] Tracked pageview:', url);
|
console.log('[Umami] Tracked pageview:', url);
|
||||||
}
|
}
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
|
||||||
if (!websiteId) return null;
|
if (!websiteId) return null;
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<Script
|
|
||||||
id="umami-analytics"
|
|
||||||
src="/stats/script.js"
|
|
||||||
data-website-id={websiteId}
|
|
||||||
data-host-url="/stats"
|
|
||||||
strategy="afterInteractive"
|
|
||||||
data-domains="klz-cables.com"
|
|
||||||
defer
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,43 @@ import Link from 'next/link';
|
|||||||
import { cn } from './utils';
|
import { cn } from './utils';
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
|
variant?:
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'accent'
|
||||||
|
| 'saturated'
|
||||||
|
| 'outline'
|
||||||
|
| 'ghost'
|
||||||
|
| 'white'
|
||||||
|
| 'destructive';
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
href?: string;
|
href?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
export function Button({
|
||||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
href,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
||||||
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
||||||
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||||
outline: 'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
outline:
|
||||||
|
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||||
ghost: 'text-primary hover:shadow-lg',
|
ghost: 'text-primary hover:shadow-lg',
|
||||||
white: 'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
white:
|
||||||
|
'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-md hover:shadow-destructive/30 hover:shadow-2xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
|||||||
outline: 'bg-primary',
|
outline: 'bg-primary',
|
||||||
ghost: 'bg-primary-light/10',
|
ghost: 'bg-primary-light/10',
|
||||||
white: 'bg-primary-light',
|
white: 'bg-primary-light',
|
||||||
|
destructive: 'bg-destructive/90',
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<span className={cn(
|
<span
|
||||||
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500",
|
className={cn(
|
||||||
variant === 'white' ? "group-hover/btn:text-primary-dark" : ""
|
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||||
)}>
|
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span
|
||||||
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out",
|
className={cn(
|
||||||
overlayColors[variant]
|
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||||
)} />
|
overlayColors[variant],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
4
cookies.txt
Normal file
4
cookies.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -398,6 +398,24 @@ locale: de
|
|||||||
"55",
|
"55",
|
||||||
"4195"
|
"4195"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"0,95",
|
||||||
|
"48,5",
|
||||||
|
"0,0247",
|
||||||
|
"3,4",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"885",
|
||||||
|
"59",
|
||||||
|
"4800"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -737,6 +755,24 @@ locale: de
|
|||||||
"60",
|
"60",
|
||||||
"4634"
|
"4634"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1,05",
|
||||||
|
"52,3",
|
||||||
|
"0,0247",
|
||||||
|
"5,5",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"990",
|
||||||
|
"66",
|
||||||
|
"5200"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1076,6 +1112,24 @@ locale: de
|
|||||||
"65",
|
"65",
|
||||||
"5093"
|
"5093"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1,15",
|
||||||
|
"57,5",
|
||||||
|
"0,0247",
|
||||||
|
"8,0",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"Auf Anfrage",
|
||||||
|
"113",
|
||||||
|
"2,4",
|
||||||
|
"1065",
|
||||||
|
"71",
|
||||||
|
"5900"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,6 +398,24 @@ locale: en
|
|||||||
"55",
|
"55",
|
||||||
"4195"
|
"4195"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"0.95",
|
||||||
|
"48.5",
|
||||||
|
"0.0247",
|
||||||
|
"3.4",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"885",
|
||||||
|
"59",
|
||||||
|
"4800"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -737,6 +755,24 @@ locale: en
|
|||||||
"60",
|
"60",
|
||||||
"4634"
|
"4634"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1.05",
|
||||||
|
"52.3",
|
||||||
|
"0.0247",
|
||||||
|
"5.5",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"990",
|
||||||
|
"66",
|
||||||
|
"5200"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1076,6 +1112,24 @@ locale: en
|
|||||||
"65",
|
"65",
|
||||||
"5093"
|
"5093"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": "1x1200/35",
|
||||||
|
"cells": [
|
||||||
|
"Al",
|
||||||
|
"RM",
|
||||||
|
"1.15",
|
||||||
|
"57.5",
|
||||||
|
"0.0247",
|
||||||
|
"8",
|
||||||
|
"On Request",
|
||||||
|
"On Request",
|
||||||
|
"113",
|
||||||
|
"2.4",
|
||||||
|
"1065",
|
||||||
|
"71",
|
||||||
|
"5900"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,27 @@ services:
|
|||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
# Docker Internal Communication
|
# Docker Internal Communication
|
||||||
DIRECTUS_URL: http://directus:8055
|
DIRECTUS_URL: http://directus:8055
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.klz-app-local.rule=Host(`klz.localhost`)"
|
# Clear all production-related TLS/Middleware settings for the main routers
|
||||||
- "traefik.http.routers.klz-app-local.entrypoints=web"
|
- "traefik.http.routers.klz-cables.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-app-local.service=klz-cables"
|
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables.middlewares="
|
||||||
|
|
||||||
|
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-web.middlewares="
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.klz-directus-local.rule=Host(`cms.klz.localhost`)"
|
- "traefik.http.routers.klz-cables-directus.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-directus-local.entrypoints=web"
|
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
|
||||||
- "traefik.http.routers.klz-directus-local.service=klz-directus"
|
- "traefik.http.routers.klz-cables-directus.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-directus.middlewares="
|
||||||
ports:
|
ports:
|
||||||
- "8055:8055"
|
- "8055:8055"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,61 +1,87 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
klz-app:
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- default
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
||||||
|
varnish:
|
||||||
|
image: varnish:7
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
volumes:
|
||||||
|
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/varnish:exec,mode=1777
|
||||||
|
environment:
|
||||||
|
VARNISH_SIZE: ${VARNISH_CACHE_SIZE:-256M}
|
||||||
|
APP_VERSION: ${IMAGE_TAG:-latest}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST}`) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||||
# HTTPS router
|
# HTTPS router
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(${TRAEFIK_HOST})"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST}`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||||
# Forwarded Headers
|
# Forwarded Headers
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
# Middlewares
|
# Middlewares
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Router (to show the login page)
|
# Gatekeeper Router (to show the login page)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST}`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
||||||
|
|
||||||
# Middleware Definitions
|
# Middleware Definitions
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/verify"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables-gatekeeper:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||||
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
|
- default
|
||||||
- infra
|
- infra
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||||
|
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
||||||
|
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST}
|
||||||
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: directus/directus:11
|
image: directus/directus:11
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
|
- default
|
||||||
- infra
|
- infra
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
@@ -75,23 +101,25 @@ services:
|
|||||||
# Error Tracking
|
# Error Tracking
|
||||||
SENTRY_DSN: ${SENTRY_DSN}
|
SENTRY_DSN: ${SENTRY_DSN}
|
||||||
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
||||||
|
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- ./directus/uploads:/directus/uploads
|
- ./directus/uploads:/directus/uploads
|
||||||
- ./directus/extensions:/directus/extensions
|
- ./directus/extensions:/directus/extensions
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(${DIRECTUS_HOST})"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
directus-db:
|
directus-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- default
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -102,6 +130,8 @@ services:
|
|||||||
- directus-db-data:/var/lib/postgresql/data
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
default:
|
||||||
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
### Build-Time Variables (NEXT_PUBLIC_*)
|
### Build-Time Variables (NEXT*PUBLIC*\*)
|
||||||
|
|
||||||
These are embedded into the JavaScript bundle during build and are visible to the client:
|
These are embedded into the JavaScript bundle during build and are visible to the client:
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
| ---------------------- | -------- | ------------------------------------------------------------ |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||||
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
|
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
|
||||||
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
|
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
|
||||||
|
|
||||||
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
||||||
|
|
||||||
@@ -58,38 +58,40 @@ These are embedded into the JavaScript bundle during build and are visible to th
|
|||||||
|
|
||||||
These are loaded from the `.env` file at runtime and are only available on the server:
|
These are loaded from the `.env` file at runtime and are only available on the server:
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
| -------------------------- | -------- | ------------------------------------------------------ |
|
||||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||||
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
||||||
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
|
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
|
||||||
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
|
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
|
||||||
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
|
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
|
||||||
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
|
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
|
||||||
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
|
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
|
||||||
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
|
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
|
||||||
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
|
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
|
||||||
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
|
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
|
||||||
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
|
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Copy the example environment file:
|
1. Copy the example environment file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Edit `.env` and fill in your local configuration:
|
2. Edit `.env` and fill in your local configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
@@ -97,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
@@ -112,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
|||||||
# Build with build-time arguments
|
# Build with build-time arguments
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||||
-t klz-cables:local .
|
-t klz-cables:local .
|
||||||
|
|
||||||
# Run with runtime environment file
|
# Run with runtime environment file
|
||||||
@@ -138,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
|
|||||||
|
|
||||||
**Build-Time Variables:**
|
**Build-Time Variables:**
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
- `UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
- `UMAMI_API_ENDPOINT` - Umami API endpoint
|
||||||
|
|
||||||
**Runtime Variables:**
|
**Runtime Variables:**
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
- `SENTRY_DSN` - Error tracking DSN
|
||||||
@@ -209,11 +212,12 @@ docker-compose logs -f app
|
|||||||
**Problem**: Build fails with "Environment validation failed"
|
**Problem**: Build fails with "Environment validation failed"
|
||||||
|
|
||||||
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||||
-t klz-cables .
|
-t klz-cables .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,6 +226,7 @@ docker build \
|
|||||||
**Problem**: Container starts but application crashes
|
**Problem**: Container starts but application crashes
|
||||||
|
|
||||||
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the server
|
# On the server
|
||||||
cat /home/deploy/sites/klz-cables.com/.env
|
cat /home/deploy/sites/klz-cables.com/.env
|
||||||
@@ -235,9 +240,11 @@ docker-compose logs app
|
|||||||
**Problem**: Features not working (email, analytics, etc.)
|
**Problem**: Features not working (email, analytics, etc.)
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
|
|
||||||
1. Check that the secret is configured in Gitea
|
1. Check that the secret is configured in Gitea
|
||||||
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
||||||
3. Redeploy to regenerate the `.env` file:
|
3. Redeploy to regenerate the `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit --allow-empty -m "Trigger redeploy"
|
git commit --allow-empty -m "Trigger redeploy"
|
||||||
git push origin main
|
git push origin main
|
||||||
@@ -255,6 +262,7 @@ docker-compose logs app
|
|||||||
**Problem**: `docker-compose up` fails with "env file not found"
|
**Problem**: `docker-compose up` fails with "env file not found"
|
||||||
|
|
||||||
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
||||||
|
|
||||||
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
||||||
2. Manually trigger a deployment by pushing to main
|
2. Manually trigger a deployment by pushing to main
|
||||||
3. If still missing, check server permissions and disk space
|
3. If still missing, check server permissions and disk space
|
||||||
@@ -264,6 +272,7 @@ docker-compose logs app
|
|||||||
**Problem**: Container can't connect to Traefik
|
**Problem**: Container can't connect to Traefik
|
||||||
|
|
||||||
**Solution**: Verify the `infra` network exists:
|
**Solution**: Verify the `infra` network exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker network ls | grep infra
|
docker network ls | grep infra
|
||||||
docker network inspect infra
|
docker network inspect infra
|
||||||
|
|||||||
@@ -7,29 +7,31 @@ This guide helps you migrate from the old fragile environment variable setup to
|
|||||||
### Before (Fragile & Overkill)
|
### Before (Fragile & Overkill)
|
||||||
|
|
||||||
❌ **Problems:**
|
❌ **Problems:**
|
||||||
|
|
||||||
- Environment variables passed individually via SSH (12+ vars)
|
- Environment variables passed individually via SSH (12+ vars)
|
||||||
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
||||||
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
|
- Build args included runtime-only variables (SENTRY*DSN, MAIL*_, REDIS\__)
|
||||||
- No single source of truth
|
- No single source of truth
|
||||||
- Difficult to maintain and error-prone
|
- Difficult to maintain and error-prone
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Old deploy.yml - FRAGILE!
|
# Old deploy.yml - FRAGILE!
|
||||||
ssh root@alpha.mintel.me \
|
ssh root@alpha.mintel.me \
|
||||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||||
... (12+ variables) \
|
... (12+ variables) \
|
||||||
/home/deploy/deploy.sh"
|
/home/deploy/deploy.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
### After (Clean & Robust)
|
### After (Clean & Robust)
|
||||||
|
|
||||||
✅ **Benefits:**
|
✅ **Benefits:**
|
||||||
|
|
||||||
- Single `.env` file on server contains all runtime variables
|
- Single `.env` file on server contains all runtime variables
|
||||||
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
||||||
- Clear separation: build-time vs runtime
|
- Clear separation: build-time vs runtime
|
||||||
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
|||||||
### Step 1: Update Gitea Secrets
|
### Step 1: Update Gitea Secrets
|
||||||
|
|
||||||
**Remove these secrets** (no longer needed in CI/CD):
|
**Remove these secrets** (no longer needed in CI/CD):
|
||||||
|
|
||||||
- ❌ `MAIL_FROM`
|
- ❌ `MAIL_FROM`
|
||||||
- ❌ `MAIL_HOST`
|
- ❌ `MAIL_HOST`
|
||||||
- ❌ `MAIL_PASSWORD`
|
- ❌ `MAIL_PASSWORD`
|
||||||
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
|||||||
- ❌ `SENTRY_DSN` (from build args)
|
- ❌ `SENTRY_DSN` (from build args)
|
||||||
|
|
||||||
**Keep these secrets** (still needed for build):
|
**Keep these secrets** (still needed for build):
|
||||||
|
|
||||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||||
- ✅ `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||||
- ✅ `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
- ✅ `UMAMI_WEBSITE_ID`
|
||||||
|
- ✅ `UMAMI_API_ENDPOINT`
|
||||||
- ✅ `REGISTRY_USER`
|
- ✅ `REGISTRY_USER`
|
||||||
- ✅ `REGISTRY_PASS`
|
- ✅ `REGISTRY_PASS`
|
||||||
- ✅ `ALPHA_SSH_KEY`
|
- ✅ `ALPHA_SSH_KEY`
|
||||||
@@ -81,8 +86,8 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
|
UMAMI_WEBSITE_ID=your-actual-id
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking
|
# Error Tracking
|
||||||
SENTRY_DSN=your-actual-dsn
|
SENTRY_DSN=your-actual-dsn
|
||||||
@@ -168,6 +173,7 @@ git push origin main
|
|||||||
```
|
```
|
||||||
|
|
||||||
The CI/CD workflow will:
|
The CI/CD workflow will:
|
||||||
|
|
||||||
1. Build with only `NEXT_PUBLIC_*` build args
|
1. Build with only `NEXT_PUBLIC_*` build args
|
||||||
2. Push to registry
|
2. Push to registry
|
||||||
3. SSH to server and run deploy.sh
|
3. SSH to server and run deploy.sh
|
||||||
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
|
|||||||
|
|
||||||
## Comparison Table
|
## Comparison Table
|
||||||
|
|
||||||
| Aspect | Before | After |
|
| Aspect | Before | After |
|
||||||
|--------|--------|-------|
|
| ----------------- | ------------------------------- | ---------------------------- |
|
||||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
|
||||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||||
| **Clarity** | Confusing duplication | Clear separation |
|
| **Clarity** | Confusing duplication | Clear separation |
|
||||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||||
|
|
||||||
## Rollback Plan
|
## Rollback Plan
|
||||||
|
|
||||||
If you need to rollback to the old system:
|
If you need to rollback to the old system:
|
||||||
|
|
||||||
1. Revert the changes in git:
|
1. Revert the changes in git:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git revert HEAD
|
git revert HEAD
|
||||||
git push origin main
|
git push origin main
|
||||||
@@ -229,7 +236,8 @@ A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the
|
|||||||
|
|
||||||
**Q: Can I update environment variables without rebuilding?**
|
**Q: Can I update environment variables without rebuilding?**
|
||||||
|
|
||||||
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
A: Yes, for runtime-only variables (MAIL*\*, REDIS*\*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nano /home/deploy/sites/klz-cables.com/.env
|
nano /home/deploy/sites/klz-cables.com/.env
|
||||||
docker-compose down && docker-compose up -d
|
docker-compose down && docker-compose up -d
|
||||||
@@ -240,6 +248,7 @@ For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they'r
|
|||||||
**Q: Where should I store the .env file backup?**
|
**Q: Where should I store the .env file backup?**
|
||||||
|
|
||||||
A: Keep a secure backup outside the server:
|
A: Keep a secure backup outside the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download from server
|
# Download from server
|
||||||
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
||||||
@@ -250,7 +259,8 @@ scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
|||||||
|
|
||||||
**Q: What if I accidentally commit .env to git?**
|
**Q: What if I accidentally commit .env to git?**
|
||||||
|
|
||||||
A:
|
A:
|
||||||
|
|
||||||
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
||||||
2. Rotate all credentials in the file
|
2. Rotate all credentials in the file
|
||||||
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
||||||
@@ -267,6 +277,7 @@ If you encounter issues during migration:
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The new system is:
|
The new system is:
|
||||||
|
|
||||||
- ✅ **Simpler**: One .env file instead of scattered variables
|
- ✅ **Simpler**: One .env file instead of scattered variables
|
||||||
- ✅ **Cleaner**: Clear separation of build vs runtime
|
- ✅ **Cleaner**: Clear separation of build vs runtime
|
||||||
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
COPY . .
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "index.js"]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const cookieParser = require('cookie-parser');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const GATEKEEPER_PASSWORD = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
|
||||||
const AUTH_COOKIE_NAME = 'klz_gatekeeper_session';
|
|
||||||
|
|
||||||
app.set('view engine', 'ejs');
|
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
// ForwardAuth check endpoint
|
|
||||||
app.get('/verify', (req, res) => {
|
|
||||||
const session = req.cookies[AUTH_COOKIE_NAME];
|
|
||||||
|
|
||||||
if (session === GATEKEEPER_PASSWORD) {
|
|
||||||
return res.status(200).send('OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traefik will use this to redirect if requested
|
|
||||||
const originalUrl = req.headers['x-forwarded-uri'] || '/';
|
|
||||||
const host = req.headers['x-forwarded-host'] || '';
|
|
||||||
const proto = req.headers['x-forwarded-proto'] || 'https';
|
|
||||||
|
|
||||||
// Redirect to login
|
|
||||||
res.redirect(`${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login page
|
|
||||||
app.get('/gatekeeper/login', (req, res) => {
|
|
||||||
res.render('login', {
|
|
||||||
error: req.query.error ? 'Invalid password' : null,
|
|
||||||
redirect: req.query.redirect || '/'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle login
|
|
||||||
app.post('/gatekeeper/login', (req, res) => {
|
|
||||||
const { password, redirect } = req.body;
|
|
||||||
|
|
||||||
if (password === GATEKEEPER_PASSWORD) {
|
|
||||||
res.cookie(AUTH_COOKIE_NAME, GATEKEEPER_PASSWORD, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
path: '/',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
||||||
});
|
|
||||||
return res.redirect(redirect || '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.redirect(`/gatekeeper/login?error=1&redirect=${encodeURIComponent(redirect || '/')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Gatekeeper listening on port ${PORT}`);
|
|
||||||
});
|
|
||||||
922
gatekeeper/package-lock.json
generated
922
gatekeeper/package-lock.json
generated
@@ -1,922 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "klz-gatekeeper",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "klz-gatekeeper",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"ejs": "^3.1.9",
|
|
||||||
"express": "^4.18.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/array-flatten": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/async": {
|
|
||||||
"version": "3.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
|
||||||
"version": "1.20.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"content-type": "~1.0.5",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"destroy": "~1.2.0",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.4.24",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"qs": "~6.14.0",
|
|
||||||
"raw-body": "~2.5.3",
|
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bound": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
|
||||||
"version": "0.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
|
||||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "5.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-type": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-parser": {
|
|
||||||
"version": "1.4.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
|
||||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "0.7.2",
|
|
||||||
"cookie-signature": "1.0.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "2.6.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/destroy": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ee-first": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ejs": {
|
|
||||||
"version": "3.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
|
||||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"jake": "^10.8.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"ejs": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/encodeurl": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/etag": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
|
||||||
"version": "4.22.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "~1.3.8",
|
|
||||||
"array-flatten": "1.1.1",
|
|
||||||
"body-parser": "~1.20.3",
|
|
||||||
"content-disposition": "~0.5.4",
|
|
||||||
"content-type": "~1.0.4",
|
|
||||||
"cookie": "~0.7.1",
|
|
||||||
"cookie-signature": "~1.0.6",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"etag": "~1.8.1",
|
|
||||||
"finalhandler": "~1.3.1",
|
|
||||||
"fresh": "~0.5.2",
|
|
||||||
"http-errors": "~2.0.0",
|
|
||||||
"merge-descriptors": "1.0.3",
|
|
||||||
"methods": "~1.1.2",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"path-to-regexp": "~0.1.12",
|
|
||||||
"proxy-addr": "~2.0.7",
|
|
||||||
"qs": "~6.14.0",
|
|
||||||
"range-parser": "~1.2.1",
|
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"send": "~0.19.0",
|
|
||||||
"serve-static": "~1.16.2",
|
|
||||||
"setprototypeof": "1.2.0",
|
|
||||||
"statuses": "~2.0.1",
|
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"utils-merge": "1.0.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/filelist": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
|
||||||
"version": "0.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
|
||||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"depd": "~2.0.0",
|
|
||||||
"inherits": "~2.0.4",
|
|
||||||
"setprototypeof": "~1.2.0",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"toidentifier": "~1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.4.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jake": {
|
|
||||||
"version": "10.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
|
||||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"async": "^3.2.6",
|
|
||||||
"filelist": "^1.0.4",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"jake": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/media-typer": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-descriptors": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/methods": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
|
||||||
"version": "5.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
|
||||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
|
||||||
"version": "1.13.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ee-first": "1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-to-regexp": {
|
|
||||||
"version": "0.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"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/proxy-addr": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"forwarded": "0.2.0",
|
|
||||||
"ipaddr.js": "1.9.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
|
||||||
"version": "6.14.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/range-parser": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body": {
|
|
||||||
"version": "2.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
|
||||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.4.24",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/send": {
|
|
||||||
"version": "0.19.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
|
||||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "2.0.0",
|
|
||||||
"destroy": "1.2.0",
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"etag": "~1.8.1",
|
|
||||||
"fresh": "~0.5.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"mime": "1.6.0",
|
|
||||||
"ms": "2.1.3",
|
|
||||||
"on-finished": "~2.4.1",
|
|
||||||
"range-parser": "~1.2.1",
|
|
||||||
"statuses": "~2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
|
||||||
"version": "1.16.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
|
||||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "~2.0.0",
|
|
||||||
"escape-html": "~1.0.3",
|
|
||||||
"parseurl": "~1.3.3",
|
|
||||||
"send": "~0.19.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-list": "^1.0.0",
|
|
||||||
"side-channel-map": "^1.0.1",
|
|
||||||
"side-channel-weakmap": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-list": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-map": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-weakmap": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-map": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/statuses": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toidentifier": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
|
||||||
"version": "1.6.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"media-typer": "0.3.0",
|
|
||||||
"mime-types": "~2.1.24"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unpipe": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/utils-merge": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "klz-gatekeeper",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Simple branded gatekeeper for Traefik ForwardAuth",
|
|
||||||
"main": "index.js",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"ejs": "^3.1.9",
|
|
||||||
"express": "^4.18.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>KLZ Cables | Access Control</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background-color: #001a4d;
|
|
||||||
color: white;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.bg-grid {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
}
|
|
||||||
.accent-glow {
|
|
||||||
box-shadow: 0 0 20px rgba(130, 237, 32, 0.4);
|
|
||||||
}
|
|
||||||
.scribble-animation {
|
|
||||||
stroke-dasharray: 1000;
|
|
||||||
stroke-dashoffset: 1000;
|
|
||||||
animation: draw 2s ease-out forwards;
|
|
||||||
}
|
|
||||||
@keyframes draw {
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen flex items-center justify-center relative">
|
|
||||||
<!-- Background Elements -->
|
|
||||||
<div class="absolute inset-0 bg-grid pointer-events-none"></div>
|
|
||||||
<div class="absolute top-0 right-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
|
||||||
<div class="absolute bottom-0 left-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
|
||||||
|
|
||||||
<div class="relative z-10 w-full max-w-md px-6">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="flex justify-center mb-12">
|
|
||||||
<svg class="h-16 w-auto" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#001a4d" />
|
|
||||||
<path d="M30 30L70 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
|
|
||||||
<path d="M70 30L30 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white/5 backdrop-blur-xl border border-white/10 p-10 rounded-[40px] shadow-2xl">
|
|
||||||
<h1 class="text-3xl font-black mb-2 tracking-tighter uppercase italic">
|
|
||||||
KLZ <span class="text-[#82ed20]">Gatekeeper</span>
|
|
||||||
</h1>
|
|
||||||
<p class="text-white/60 text-sm mb-8">This environment is strictly protected.</p>
|
|
||||||
|
|
||||||
<% if (error) { %>
|
|
||||||
<div class="bg-red-500/20 border border-red-500/50 text-red-200 p-4 rounded-2xl mb-6 text-sm flex items-center gap-3">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
||||||
<%= error %>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<form action="/gatekeeper/login" method="POST" class="space-y-6">
|
|
||||||
<input type="hidden" name="redirect" value="<%= redirect %>">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2 ml-4">Access Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 focus:outline-none focus:border-[#82ed20]/50 transition-all text-lg tracking-widest text-center"
|
|
||||||
placeholder="••••••••"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full bg-[#82ed20] text-[#001a4d] font-black uppercase tracking-[0.2em] py-5 rounded-2xl hover:bg-[#82ed20]/90 transition-all accent-glow active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
Enter Workspace →
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 text-center">
|
|
||||||
<p class="text-[10px] font-bold text-white/20 uppercase tracking-[0.4em]">
|
|
||||||
© 2026 KLZ Vertriebs GmbH
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
40
lib/blog.ts
40
lib/blog.ts
@@ -41,11 +41,11 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||||
if (!fs.existsSync(postsDir)) return [];
|
if (!fs.existsSync(postsDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(postsDir);
|
const files = fs.readdirSync(postsDir);
|
||||||
const posts = files
|
const posts = files
|
||||||
.filter(file => file.endsWith('.mdx'))
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
.map(file => {
|
.map((file) => {
|
||||||
const filePath = path.join(postsDir, file);
|
const filePath = path.join(postsDir, file);
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
|
.sort(
|
||||||
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
return posts;
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdjacentPosts(slug: string, locale: string): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
||||||
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||||
|
if (!fs.existsSync(postsDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(postsDir);
|
||||||
|
return files
|
||||||
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
|
.map((file) => {
|
||||||
|
const filePath = path.join(postsDir, file);
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: file.replace(/\.mdx$/, ''),
|
||||||
|
frontmatter: data as PostFrontmatter,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.frontmatter.date as string).getTime() -
|
||||||
|
new Date(a.frontmatter.date as string).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdjacentPosts(
|
||||||
|
slug: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const currentIndex = posts.findIndex(post => post.slug === slug);
|
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
return { prev: null, next: null };
|
return { prev: null, next: null };
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
|||||||
function createConfig() {
|
function createConfig() {
|
||||||
const env = envSchema.parse(getRawEnv());
|
const env = envSchema.parse(getRawEnv());
|
||||||
|
|
||||||
|
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
env: env.NODE_ENV,
|
env: env.NODE_ENV,
|
||||||
isProduction: env.NODE_ENV === 'production',
|
target,
|
||||||
isDevelopment: env.NODE_ENV === 'development',
|
isProduction: target === 'production' || !target,
|
||||||
isTest: env.NODE_ENV === 'test',
|
isStaging: target === 'staging',
|
||||||
|
isTesting: target === 'testing',
|
||||||
|
isDevelopment: target === 'development',
|
||||||
|
|
||||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
// The proxied path used in the frontend
|
|
||||||
proxyPath: '/stats/script.js',
|
|
||||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -65,6 +67,13 @@ function createConfig() {
|
|||||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||||
proxyPath: '/cms',
|
proxyPath: '/cms',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
gotify: {
|
||||||
|
url: env.GOTIFY_URL,
|
||||||
|
token: env.GOTIFY_TOKEN,
|
||||||
|
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +96,21 @@ export const config = {
|
|||||||
get env() {
|
get env() {
|
||||||
return getConfig().env;
|
return getConfig().env;
|
||||||
},
|
},
|
||||||
|
get target() {
|
||||||
|
return getConfig().target;
|
||||||
|
},
|
||||||
get isProduction() {
|
get isProduction() {
|
||||||
return getConfig().isProduction;
|
return getConfig().isProduction;
|
||||||
},
|
},
|
||||||
|
get isStaging() {
|
||||||
|
return getConfig().isStaging;
|
||||||
|
},
|
||||||
|
get isTesting() {
|
||||||
|
return getConfig().isTesting;
|
||||||
|
},
|
||||||
get isDevelopment() {
|
get isDevelopment() {
|
||||||
return getConfig().isDevelopment;
|
return getConfig().isDevelopment;
|
||||||
},
|
},
|
||||||
get isTest() {
|
|
||||||
return getConfig().isTest;
|
|
||||||
},
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return getConfig().baseUrl;
|
return getConfig().baseUrl;
|
||||||
},
|
},
|
||||||
@@ -117,6 +132,9 @@ export const config = {
|
|||||||
get directus() {
|
get directus() {
|
||||||
return getConfig().directus;
|
return getConfig().directus;
|
||||||
},
|
},
|
||||||
|
get notifications() {
|
||||||
|
return getConfig().notifications;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +150,7 @@ export function getMaskedConfig() {
|
|||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: mask(c.analytics.umami.websiteId),
|
websiteId: mask(c.analytics.umami.websiteId),
|
||||||
scriptUrl: c.analytics.umami.scriptUrl,
|
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||||
enabled: c.analytics.umami.enabled,
|
enabled: c.analytics.umami.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -161,5 +179,12 @@ export function getMaskedConfig() {
|
|||||||
password: mask(c.directus.password),
|
password: mask(c.directus.password),
|
||||||
token: mask(c.directus.token),
|
token: mask(c.directus.token),
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
gotify: {
|
||||||
|
url: c.notifications.gotify.url,
|
||||||
|
token: mask(c.notifications.gotify.token),
|
||||||
|
enabled: c.notifications.gotify.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
|
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
import { getServerAppServices } from './services/create-services.server';
|
||||||
|
|
||||||
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
|
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
|
||||||
|
|
||||||
// Use internal URL if on server to bypass Gatekeeper/Auth
|
// Use internal URL if on server to bypass Gatekeeper/Auth
|
||||||
const effectiveUrl = typeof window === 'undefined' && internalUrl ? internalUrl : url;
|
// Use proxy path in browser to stay on the same origin
|
||||||
|
const effectiveUrl =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? internalUrl || url
|
||||||
|
: typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}${proxyPath}`
|
||||||
|
: proxyPath;
|
||||||
|
|
||||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to determine if we should show detailed errors
|
||||||
|
*/
|
||||||
|
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genericizes error messages for production/staging
|
||||||
|
*/
|
||||||
|
function formatError(error: any) {
|
||||||
|
if (shouldShowDevErrors) {
|
||||||
|
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
return 'A system error occurred. Our team has been notified.';
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureAuthenticated() {
|
export async function ensureAuthenticated() {
|
||||||
if (token) {
|
if (token) {
|
||||||
client.setToken(token);
|
client.setToken(token);
|
||||||
@@ -17,6 +39,9 @@ export async function ensureAuthenticated() {
|
|||||||
try {
|
try {
|
||||||
await client.login(adminEmail, password);
|
await client.login(adminEmail, password);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||||
|
}
|
||||||
console.error('Failed to authenticate with Directus:', e);
|
console.error('Failed to authenticate with Directus:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +86,9 @@ export async function getProducts(locale: string = 'de') {
|
|||||||
);
|
);
|
||||||
return items.map((item) => mapDirectusProduct(item, locale));
|
return items.map((item) => mapDirectusProduct(item, locale));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
|
||||||
|
}
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -86,6 +114,12 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
|
|||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
return mapDirectusProduct(items[0], locale);
|
return mapDirectusProduct(items[0], locale);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, {
|
||||||
|
part: 'directus_get_product_by_slug',
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error(`Error fetching product ${slug}:`, error);
|
console.error(`Error fetching product ${slug}:`, error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -98,20 +132,27 @@ export async function checkHealth() {
|
|||||||
await ensureAuthenticated();
|
await ensureAuthenticated();
|
||||||
await client.request(readCollections());
|
await client.request(readCollections());
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||||
|
}
|
||||||
console.error('Directus authentication failed during health check:', e);
|
console.error('Directus authentication failed during health check:', e);
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message:
|
message: shouldShowDevErrors
|
||||||
'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.',
|
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
|
||||||
|
: 'CMS is currently unavailable due to an internal authentication error.',
|
||||||
code: 'AUTH_FAILED',
|
code: 'AUTH_FAILED',
|
||||||
details: e.message,
|
details: shouldShowDevErrors ? e.message : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Schema check (does the products table exist?)
|
// 2. Schema check (does the contact_submissions table exist?)
|
||||||
try {
|
try {
|
||||||
await client.request(readItems('products', { limit: 1 }));
|
await client.request(readItems('contact_submissions', { limit: 1 }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
e.message?.includes('does not exist') ||
|
e.message?.includes('does not exist') ||
|
||||||
e.code === 'INVALID_PAYLOAD' ||
|
e.code === 'INVALID_PAYLOAD' ||
|
||||||
@@ -119,23 +160,30 @@ export async function checkHealth() {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'The "products" collection is missing or inaccessible. Please sync your data.',
|
message: shouldShowDevErrors
|
||||||
|
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
|
||||||
|
: 'Required data structures are currently unavailable.',
|
||||||
code: 'SCHEMA_MISSING',
|
code: 'SCHEMA_MISSING',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `Schema error: ${e.message}`,
|
message: shouldShowDevErrors
|
||||||
|
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
|
||||||
|
: 'The data schema is currently misconfigured.',
|
||||||
code: 'SCHEMA_ERROR',
|
code: 'SCHEMA_ERROR',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
|
||||||
|
}
|
||||||
console.error('Directus health check failed with unexpected error:', error);
|
console.error('Directus health check failed with unexpected error:', error);
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: error.message || 'An unexpected error occurred while connecting to the CMS.',
|
message: formatError(error),
|
||||||
code: error.code || 'UNKNOWN',
|
code: error.code || 'UNKNOWN',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
70
lib/env.test.ts
Normal file
70
lib/env.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { envSchema } from './env';
|
||||||
|
|
||||||
|
describe('envSchema', () => {
|
||||||
|
it('should allow missing MAIL_HOST in development', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||||
|
TARGET: 'development',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in production', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in testing', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://testing.example.com',
|
||||||
|
TARGET: 'testing',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require MAIL_HOST in staging', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://staging.example.com',
|
||||||
|
TARGET: 'staging',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
'MAIL_HOST is required in non-development environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass if MAIL_HOST is provided in production', () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
MAIL_HOST: 'smtp.example.com',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true', () => {
|
||||||
|
process.env.SKIP_RUNTIME_ENV_VALIDATION = 'true';
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||||
|
TARGET: 'production',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
|
||||||
|
});
|
||||||
|
});
|
||||||
102
lib/env.ts
102
lib/env.ts
@@ -8,44 +8,68 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
|||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
export const envSchema = z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.object({
|
||||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||||
|
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
|
|
||||||
// Analytics
|
// Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
|
UMAMI_API_ENDPOINT: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
z.string().url().default('https://analytics.infra.mintel.me'),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error Tracking
|
// Error Tracking
|
||||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
|
||||||
// Mail
|
// Mail
|
||||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||||
z.array(z.string()).default([]),
|
z.array(z.string()).default([]),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Directus
|
// Directus
|
||||||
DIRECTUS_URL: z.preprocess(
|
DIRECTUS_URL: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default('http://localhost:8055'),
|
z.string().url().default('http://localhost:8055'),
|
||||||
),
|
),
|
||||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
});
|
|
||||||
|
// Deploy Target
|
||||||
|
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
|
// Gotify
|
||||||
|
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||||
|
const isDev = target === 'development' || !target;
|
||||||
|
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
|
||||||
|
const isServer = typeof window === 'undefined';
|
||||||
|
|
||||||
|
// Only enforce server-only variables when running on the server.
|
||||||
|
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||||
|
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'MAIL_HOST is required in non-development environments',
|
||||||
|
path: ['MAIL_HOST'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
@@ -57,8 +81,13 @@ export function getRawEnv() {
|
|||||||
return {
|
return {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID:
|
||||||
|
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID,
|
||||||
|
UMAMI_API_ENDPOINT:
|
||||||
|
process.env.UMAMI_API_ENDPOINT ||
|
||||||
|
process.env.UMAMI_SCRIPT_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||||
MAIL_HOST: process.env.MAIL_HOST,
|
MAIL_HOST: process.env.MAIL_HOST,
|
||||||
@@ -72,5 +101,8 @@ export function getRawEnv() {
|
|||||||
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
||||||
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
||||||
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
||||||
|
TARGET: process.env.TARGET,
|
||||||
|
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||||
|
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
59
lib/mail/mailer.test.ts
Normal file
59
lib/mail/mailer.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { sendEmail } from './mailer';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
// Mock getServerAppServices to avoid full app initialization
|
||||||
|
vi.mock('@/lib/services/create-services.server', () => ({
|
||||||
|
getServerAppServices: () => ({
|
||||||
|
logger: {
|
||||||
|
child: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('../config', () => ({
|
||||||
|
config: {
|
||||||
|
mail: {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
user: 'user',
|
||||||
|
pass: 'pass',
|
||||||
|
from: 'from@example.com',
|
||||||
|
recipients: ['to@example.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('mailer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendEmail', () => {
|
||||||
|
it('should throw error if MAIL_HOST is missing', async () => {
|
||||||
|
// Temporarily nullify host
|
||||||
|
const originalHost = config.mail.host;
|
||||||
|
(config.mail as any).host = '';
|
||||||
|
|
||||||
|
const result = await sendEmail({
|
||||||
|
subject: 'Test',
|
||||||
|
html: '<p>Test</p>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('MAIL_HOST is not configured');
|
||||||
|
|
||||||
|
// Restore host
|
||||||
|
(config.mail as any).host = originalHost;
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real environment, we'd mock nodemailer, but for now we focus on the validation logic
|
||||||
|
// we added. Full SMTP integration tests are usually out of scope for unit tests.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,33 +1,44 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from 'nodemailer';
|
||||||
import { render } from "@react-email/components";
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
import { ReactElement } from "react";
|
import { config } from '../config';
|
||||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
import { ReactElement } from 'react';
|
||||||
import { config } from "../config";
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
let transporterInstance: nodemailer.Transporter | null = null;
|
||||||
host: config.mail.host,
|
|
||||||
port: config.mail.port,
|
function getTransporter() {
|
||||||
secure: config.mail.port === 465,
|
if (transporterInstance) return transporterInstance;
|
||||||
auth: {
|
|
||||||
user: config.mail.user,
|
if (!config.mail.host) {
|
||||||
pass: config.mail.pass,
|
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
transporterInstance = nodemailer.createTransport({
|
||||||
|
host: config.mail.host,
|
||||||
|
port: config.mail.port,
|
||||||
|
secure: config.mail.port === 465,
|
||||||
|
auth: {
|
||||||
|
user: config.mail.user,
|
||||||
|
pass: config.mail.pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transporterInstance;
|
||||||
|
}
|
||||||
|
|
||||||
interface SendEmailOptions {
|
interface SendEmailOptions {
|
||||||
to?: string | string[];
|
to?: string | string[];
|
||||||
|
replyTo?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
template: ReactElement;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||||
const html = await render(template);
|
|
||||||
|
|
||||||
const recipients = to || config.mail.recipients;
|
const recipients = to || config.mail.recipients;
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: config.mail.from,
|
from: config.mail.from,
|
||||||
to: recipients,
|
to: recipients,
|
||||||
|
replyTo,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
};
|
};
|
||||||
@@ -35,11 +46,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
|||||||
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await getTransporter().sendMail(mailOptions);
|
||||||
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients });
|
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||||
return { success: true, messageId: info.messageId };
|
return { success: true, messageId: info.messageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error sending email", { error, subject, recipients });
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
return { success: false, error };
|
logger.error('Error sending email', { error: errorMsg, subject, recipients });
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/mdx.ts
82
lib/mdx.ts
@@ -18,11 +18,61 @@ export interface ProductMdx {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProductMetadata(
|
||||||
|
slug: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<Partial<ProductMdx> | null> {
|
||||||
|
// Map translated slug to file slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
|
|
||||||
|
// Try exact slug first
|
||||||
|
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Try with -2 suffix (common in the dumped files)
|
||||||
|
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Fallback to English if locale is not 'en'
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||||
|
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
|
||||||
|
if (!fs.existsSync(enFilePath)) {
|
||||||
|
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(enFilePath)) {
|
||||||
|
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: {
|
||||||
|
...data,
|
||||||
|
isFallback: true,
|
||||||
|
} as ProductFrontmatter & { isFallback?: boolean },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: data as ProductFrontmatter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
||||||
// Map translated slug to file slug
|
// Map translated slug to file slug
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
|
|
||||||
// Try exact slug first
|
// Try exact slug first
|
||||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||||
|
|
||||||
@@ -41,7 +91,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
if (!fs.existsSync(enFilePath)) {
|
if (!fs.existsSync(enFilePath)) {
|
||||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(enFilePath)) {
|
if (fs.existsSync(enFilePath)) {
|
||||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent);
|
const { data, content } = matter(fileContent);
|
||||||
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
slug: fileSlug,
|
slug: fileSlug,
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
...data,
|
...data,
|
||||||
isFallback: true
|
isFallback: true,
|
||||||
} as ProductFrontmatter & { isFallback?: boolean },
|
} as ProductFrontmatter & { isFallback?: boolean },
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out products without images
|
// Filter out products without images
|
||||||
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
|
if (
|
||||||
|
product &&
|
||||||
|
(!product.frontmatter.images ||
|
||||||
|
product.frontmatter.images.length === 0 ||
|
||||||
|
!product.frontmatter.images[0])
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||||
if (!fs.existsSync(productsDir)) return [];
|
if (!fs.existsSync(productsDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(productsDir);
|
const files = fs.readdirSync(productsDir);
|
||||||
return files.filter(file => file.endsWith('.mdx')).map(file => file.replace(/\.mdx$/, ''));
|
return files.filter((file) => file.endsWith('.mdx')).map((file) => file.replace(/\.mdx$/, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||||
@@ -91,6 +146,19 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await Promise.all(allSlugs.map(slug => getProductBySlug(slug, locale)));
|
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
|
||||||
return products.filter((p): p is ProductMdx => p !== null);
|
return products.filter((p): p is ProductMdx => p !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
|
||||||
|
const slugs = await getAllProductSlugs(locale);
|
||||||
|
let allSlugs = slugs;
|
||||||
|
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const enSlugs = await getAllProductSlugs('en');
|
||||||
|
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
|
||||||
|
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
|
||||||
|
}
|
||||||
|
|||||||
31
lib/pages.ts
31
lib/pages.ts
@@ -39,23 +39,42 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
|
|||||||
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
||||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||||
if (!fs.existsSync(pagesDir)) return [];
|
if (!fs.existsSync(pagesDir)) return [];
|
||||||
|
|
||||||
const files = fs.readdirSync(pagesDir);
|
const files = fs.readdirSync(pagesDir);
|
||||||
const pages = await Promise.all(
|
const pages = await Promise.all(
|
||||||
files
|
files
|
||||||
.filter(file => file.endsWith('.mdx'))
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
.map(file => {
|
.map((file) => {
|
||||||
const fileSlug = file.replace(/\.mdx$/, '');
|
const fileSlug = file.replace(/\.mdx$/, '');
|
||||||
const filePath = path.join(pagesDir, file);
|
const filePath = path.join(pagesDir, file);
|
||||||
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContent.content);
|
const { data, content } = matter(fileContent);
|
||||||
return {
|
return {
|
||||||
slug: fileSlug,
|
slug: fileSlug,
|
||||||
frontmatter: data as PageFrontmatter,
|
frontmatter: data as PageFrontmatter,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return pages.filter((p): p is PageMdx => p !== null);
|
return pages.filter((p): p is PageMdx => p !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||||
|
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||||
|
if (!fs.existsSync(pagesDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(pagesDir);
|
||||||
|
return files
|
||||||
|
.filter((file) => file.endsWith('.mdx'))
|
||||||
|
.map((file) => {
|
||||||
|
const fileSlug = file.replace(/\.mdx$/, '');
|
||||||
|
const filePath = path.join(pagesDir, file);
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data } = matter(fileContent);
|
||||||
|
return {
|
||||||
|
slug: fileSlug,
|
||||||
|
frontmatter: data as PageFrontmatter,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||||
|
import { config } from '../../config';
|
||||||
/**
|
|
||||||
* Type definition for the Umami global object.
|
|
||||||
*
|
|
||||||
* This represents the `window.umami` object that the Umami script exposes.
|
|
||||||
* The `track` function can accept either an event name or a URL.
|
|
||||||
*/
|
|
||||||
type UmamiGlobal = {
|
|
||||||
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for UmamiAnalyticsService.
|
* Configuration options for UmamiAnalyticsService.
|
||||||
@@ -20,133 +11,90 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Umami Analytics Service Implementation.
|
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||||
*
|
*
|
||||||
* This service implements the AnalyticsService interface for Umami analytics.
|
* This version implements the Umami tracking protocol directly via fetch,
|
||||||
* It provides type-safe event tracking and pageview tracking.
|
* eliminating the need to load an external script.js file.
|
||||||
*
|
*
|
||||||
* @example
|
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||||
* ```typescript
|
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||||
* // Service creation (usually done by create-services.ts)
|
|
||||||
* const service = new UmamiAnalyticsService({ enabled: true });
|
|
||||||
*
|
|
||||||
* // Track events
|
|
||||||
* service.track('button_click', { button_id: 'cta' });
|
|
||||||
* service.trackPageview('/products/123');
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Using through the service layer (recommended)
|
|
||||||
* import { getAppServices } from '@/lib/services/create-services';
|
|
||||||
*
|
|
||||||
* const services = getAppServices();
|
|
||||||
* services.analytics.track('product_add_to_cart', {
|
|
||||||
* product_id: '123',
|
|
||||||
* price: 99.99,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
|
private websiteId?: string;
|
||||||
|
private endpoint: string;
|
||||||
|
|
||||||
|
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||||
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
|
|
||||||
|
// On server, use the full internal URL; on client, use the proxied path
|
||||||
|
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track a custom event with optional properties.
|
* Internal method to send the payload to Umami API.
|
||||||
*
|
*/
|
||||||
* This method checks if analytics are enabled and if we're in a browser environment
|
private async sendPayload(type: 'event', data: Record<string, any>) {
|
||||||
* before attempting to track the event.
|
if (!this.options.enabled || !this.websiteId) return;
|
||||||
*
|
|
||||||
* @param eventName - The name of the event to track
|
try {
|
||||||
* @param props - Optional event properties
|
const payload = {
|
||||||
*
|
website: this.websiteId,
|
||||||
* @example
|
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
|
||||||
* ```typescript
|
screen:
|
||||||
* service.track('product_add_to_cart', {
|
typeof window !== 'undefined'
|
||||||
* product_id: '123',
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
* product_name: 'Cable',
|
: undefined,
|
||||||
* price: 99.99,
|
language: typeof window !== 'undefined' ? navigator.language : undefined,
|
||||||
* quantity: 1,
|
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
|
||||||
* });
|
...data,
|
||||||
* ```
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
// Use keepalive for page navigation events to ensure they complete
|
||||||
|
keepalive: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
if (!response.ok && process.env.NODE_ENV === 'development') {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('[Umami] Failed to send analytics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event.
|
||||||
*/
|
*/
|
||||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||||
if (!this.options.enabled) return;
|
this.sendPayload('event', {
|
||||||
|
name: eventName,
|
||||||
// Server-side tracking via proxy
|
data: props,
|
||||||
if (typeof window === 'undefined') {
|
url:
|
||||||
const { getServerAppServices } = require('../create-services.server');
|
typeof window !== 'undefined'
|
||||||
const { config } = require('../../config');
|
? window.location.pathname + window.location.search
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
: undefined,
|
||||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
});
|
||||||
|
|
||||||
if (!websiteId) return;
|
|
||||||
|
|
||||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
|
||||||
logger.info('Sending analytics event', { eventName, props });
|
|
||||||
|
|
||||||
fetch(`${umamiUrl}/api/send`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
|
||||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, name: eventName, data: props } }),
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error('Failed to send analytics event', { eventName, props, error });
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
|
||||||
umami?.track?.(eventName, props);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track a pageview.
|
* Track a pageview.
|
||||||
*
|
|
||||||
* This method checks if analytics are enabled and if we're in a browser environment
|
|
||||||
* before attempting to track the pageview.
|
|
||||||
*
|
|
||||||
* Umami treats `track(url)` as a pageview override, so we can use the same
|
|
||||||
* `track` function for both events and pageviews.
|
|
||||||
*
|
|
||||||
* @param url - The URL to track (defaults to current location)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Track current page
|
|
||||||
* service.trackPageview();
|
|
||||||
*
|
|
||||||
* // Track custom URL
|
|
||||||
* service.trackPageview('/products/123?category=cables');
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
trackPageview(url?: string) {
|
trackPageview(url?: string) {
|
||||||
if (!this.options.enabled) return;
|
this.sendPayload('event', {
|
||||||
|
url:
|
||||||
// Server-side tracking via proxy
|
url ||
|
||||||
if (typeof window === 'undefined') {
|
(typeof window !== 'undefined'
|
||||||
const { getServerAppServices } = require('../create-services.server');
|
? window.location.pathname + window.location.search
|
||||||
const { config } = require('../../config');
|
: undefined),
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
});
|
||||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
|
||||||
|
|
||||||
if (!websiteId || !url) return;
|
|
||||||
|
|
||||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
|
||||||
logger.info('Sending analytics pageview', { url });
|
|
||||||
|
|
||||||
fetch(`${umamiUrl}/api/send`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
|
||||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, url } }),
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error('Failed to send analytics pageview', { url, error });
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
|
||||||
|
|
||||||
// Umami treats `track(url)` as a pageview override.
|
|
||||||
if (url) umami?.track?.(url);
|
|
||||||
else umami?.track?.(window.location.pathname + window.location.search);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
|
|||||||
import type { CacheService } from './cache/cache-service';
|
import type { CacheService } from './cache/cache-service';
|
||||||
import type { ErrorReportingService } from './errors/error-reporting-service';
|
import type { ErrorReportingService } from './errors/error-reporting-service';
|
||||||
import type { LoggerService } from './logging/logger-service';
|
import type { LoggerService } from './logging/logger-service';
|
||||||
|
import type { NotificationService } from './notifications/notification-service';
|
||||||
|
|
||||||
// Simple constructor-based DI container.
|
// Simple constructor-based DI container.
|
||||||
export class AppServices {
|
export class AppServices {
|
||||||
@@ -9,6 +10,7 @@ export class AppServices {
|
|||||||
public readonly analytics: AnalyticsService,
|
public readonly analytics: AnalyticsService,
|
||||||
public readonly errors: ErrorReportingService,
|
public readonly errors: ErrorReportingService,
|
||||||
public readonly cache: CacheService,
|
public readonly cache: CacheService,
|
||||||
public readonly logger: LoggerService
|
public readonly logger: LoggerService,
|
||||||
|
public readonly notifications: NotificationService,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
|||||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||||
|
import {
|
||||||
|
GotifyNotificationService,
|
||||||
|
NoopNotificationService,
|
||||||
|
} from './notifications/gotify-notification-service';
|
||||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||||
import { config, getMaskedConfig } from '../config';
|
import { config, getMaskedConfig } from '../config';
|
||||||
|
|
||||||
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create logger first to log initialization
|
// Create logger first to log initialization
|
||||||
const logger = new PinoLoggerService('server');
|
const logger = new PinoLoggerService('server');
|
||||||
|
|
||||||
logger.info('Initializing server application services', {
|
logger.info('Initializing server application services', {
|
||||||
environment: getMaskedConfig(),
|
environment: getMaskedConfig(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -23,6 +27,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
umamiEnabled: config.analytics.umami.enabled,
|
umamiEnabled: config.analytics.umami.enabled,
|
||||||
sentryEnabled: config.errors.glitchtip.enabled,
|
sentryEnabled: config.errors.glitchtip.enabled,
|
||||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||||
|
gotifyEnabled: config.notifications.gotify.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const analytics = config.analytics.umami.enabled
|
const analytics = config.analytics.umami.enabled
|
||||||
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
|
|||||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifications = config.notifications.gotify.enabled
|
||||||
|
? new GotifyNotificationService({
|
||||||
|
url: config.notifications.gotify.url!,
|
||||||
|
token: config.notifications.gotify.token!,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
: new NoopNotificationService();
|
||||||
|
|
||||||
|
if (config.notifications.gotify.enabled) {
|
||||||
|
logger.info('Gotify notification service initialized');
|
||||||
|
} else {
|
||||||
|
logger.info('Noop notification service initialized (notifications disabled)');
|
||||||
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true })
|
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
logger.info('GlitchTip error reporting service initialized');
|
logger.info('GlitchTip error reporting service initialized', {
|
||||||
|
dsnPresent: Boolean(config.errors.glitchtip.dsn),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||||
}
|
}
|
||||||
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
|
|||||||
level: config.logging.level,
|
level: config.logging.level,
|
||||||
});
|
});
|
||||||
|
|
||||||
singleton = new AppServices(analytics, errors, cache, logger);
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporti
|
|||||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||||
import { NoopLoggerService } from './logging/noop-logger-service';
|
import { NoopLoggerService } from './logging/noop-logger-service';
|
||||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||||
|
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||||
import { config, getMaskedConfig } from '../config';
|
import { config, getMaskedConfig } from '../config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +28,7 @@ let singleton: AppServices | undefined;
|
|||||||
* - Cache service (in-memory)
|
* - Cache service (in-memory)
|
||||||
*
|
*
|
||||||
* The services are configured based on environment variables:
|
* The services are configured based on environment variables:
|
||||||
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
|
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||||
*
|
*
|
||||||
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create logger first to log initialization
|
// Create logger first to log initialization
|
||||||
const logger =
|
const logger =
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
|
||||||
? new PinoLoggerService('server')
|
|
||||||
: new NoopLoggerService();
|
|
||||||
|
|
||||||
// Log initialization
|
// Log initialization
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -121,7 +120,9 @@ export function getAppServices(): AppServices {
|
|||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
logger.info(`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`);
|
logger.info(
|
||||||
|
`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||||
}
|
}
|
||||||
@@ -138,9 +139,10 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create and cache the singleton
|
// Create and cache the singleton
|
||||||
singleton = new AppServices(analytics, errors, cache, logger);
|
const notifications = new NoopNotificationService();
|
||||||
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ export type ErrorReportingUser = {
|
|||||||
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
||||||
|
|
||||||
export interface ErrorReportingService {
|
export interface ErrorReportingService {
|
||||||
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
|
captureException(
|
||||||
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
|
captureMessage(
|
||||||
|
message: string,
|
||||||
|
level?: ErrorReportingLevel,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
setUser(user: ErrorReportingUser | null): void;
|
setUser(user: ErrorReportingUser | null): void;
|
||||||
setTag(key: string, value: string): void;
|
setTag(key: string, value: string): void;
|
||||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ErrorReportingService,
|
ErrorReportingService,
|
||||||
ErrorReportingUser,
|
ErrorReportingUser,
|
||||||
} from './error-reporting-service';
|
} from './error-reporting-service';
|
||||||
|
import type { NotificationService } from '../notifications/notification-service';
|
||||||
|
|
||||||
type SentryLike = typeof Sentry;
|
type SentryLike = typeof Sentry;
|
||||||
|
|
||||||
@@ -15,12 +16,29 @@ export type GlitchtipErrorReportingServiceOptions = {
|
|||||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
private readonly sentry: SentryLike = Sentry
|
private readonly notifications?: NotificationService,
|
||||||
|
private readonly sentry: SentryLike = Sentry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
captureException(error: unknown, context?: Record<string, unknown>) {
|
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
return this.sentry.captureException(error, context as any) as any;
|
const result = this.sentry.captureException(error, context as any) as any;
|
||||||
|
|
||||||
|
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||||
|
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
||||||
|
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
||||||
|
if (this.notifications) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||||
|
|
||||||
|
await this.notifications.notify({
|
||||||
|
title: '🔥 Critical Error Captured',
|
||||||
|
message: `Error: ${errorMessage}${contextStr}`,
|
||||||
|
priority: 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service';
|
import type {
|
||||||
|
ErrorReportingLevel,
|
||||||
|
ErrorReportingService,
|
||||||
|
ErrorReportingUser,
|
||||||
|
} from './error-reporting-service';
|
||||||
|
|
||||||
export class NoopErrorReportingService implements ErrorReportingService {
|
export class NoopErrorReportingService implements ErrorReportingService {
|
||||||
captureException(_error: unknown, _context?: Record<string, unknown>) {
|
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
|
|||||||
// In Next.js, especially in the Edge runtime or during instrumentation,
|
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||||
// pino transports (which use worker threads) can cause issues.
|
// pino transports (which use worker threads) can cause issues.
|
||||||
// We disable transport in production and during instrumentation.
|
// We disable transport in production and during instrumentation.
|
||||||
const useTransport = !config.isProduction && typeof window === 'undefined';
|
const useTransport = config.isDevelopment && typeof window === 'undefined';
|
||||||
|
|
||||||
this.logger = pino({
|
this.logger = pino({
|
||||||
name: name || 'app',
|
name: name || 'app',
|
||||||
level: config.logging.level,
|
level: config.logging.level,
|
||||||
transport:
|
transport: useTransport
|
||||||
useTransport
|
? {
|
||||||
? {
|
target: 'pino-pretty',
|
||||||
target: 'pino-pretty',
|
options: {
|
||||||
options: {
|
colorize: true,
|
||||||
colorize: true,
|
},
|
||||||
},
|
}
|
||||||
}
|
: undefined,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/services/notifications/gotify-notification-service.ts
Normal file
49
lib/services/notifications/gotify-notification-service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NotificationOptions, NotificationService } from './notification-service';
|
||||||
|
|
||||||
|
export interface GotifyConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GotifyNotificationService implements NotificationService {
|
||||||
|
constructor(private config: GotifyConfig) {}
|
||||||
|
|
||||||
|
async notify(options: NotificationOptions): Promise<void> {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, message, priority = 4 } = options;
|
||||||
|
const url = new URL('message', this.config.url);
|
||||||
|
url.searchParams.set('token', this.config.token);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Gotify notification failed:', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gotify notification error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoopNotificationService implements NotificationService {
|
||||||
|
async notify(): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
9
lib/services/notifications/notification-service.ts
Normal file
9
lib/services/notifications/notification-service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationService {
|
||||||
|
notify(options: NotificationOptions): Promise<void>;
|
||||||
|
}
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
ci: {
|
ci: {
|
||||||
collect: {
|
collect: {
|
||||||
numberOfRuns: 1,
|
numberOfRuns: 1,
|
||||||
settings: {
|
settings: {
|
||||||
preset: 'desktop',
|
preset: 'desktop',
|
||||||
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
assert: {
|
|
||||||
assertions: {
|
|
||||||
'categories:performance': ['warn', { minScore: 0.9 }],
|
|
||||||
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
|
||||||
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
|
||||||
'categories:seo': ['warn', { minScore: 0.9 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
upload: {
|
|
||||||
target: 'temporary-public-storage',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
assert: {
|
||||||
|
assertions: {
|
||||||
|
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||||
|
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,7 +191,14 @@
|
|||||||
"emailPlaceholder": "ihre@email.de",
|
"emailPlaceholder": "ihre@email.de",
|
||||||
"message": "Nachricht",
|
"message": "Nachricht",
|
||||||
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
||||||
"submit": "Nachricht senden"
|
"submit": "Nachricht senden",
|
||||||
|
"submitting": "Wird gesendet...",
|
||||||
|
"successTitle": "Nachricht gesendet!",
|
||||||
|
"successDesc": "Vielen Dank für Ihre Nachricht. Wir werden uns so schnell wie möglich bei Ihnen melden.",
|
||||||
|
"sendAnother": "Weitere Nachricht senden",
|
||||||
|
"errorTitle": "Senden fehlgeschlagen!",
|
||||||
|
"error": "Etwas ist schief gelaufen. Bitte überprüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
|
||||||
|
"tryAgain": "Erneut versuchen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Products": {
|
"Products": {
|
||||||
|
|||||||
@@ -191,7 +191,14 @@
|
|||||||
"emailPlaceholder": "your@email.com",
|
"emailPlaceholder": "your@email.com",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"messagePlaceholder": "How can we help you?",
|
"messagePlaceholder": "How can we help you?",
|
||||||
"submit": "Send Message"
|
"submit": "Send Message",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"successTitle": "Message Sent!",
|
||||||
|
"successDesc": "Thank you for your message. We will get back to you as soon as possible.",
|
||||||
|
"sendAnother": "Send another message",
|
||||||
|
"errorTitle": "Submission Failed!",
|
||||||
|
"error": "Something went wrong. Please check your input and try again.",
|
||||||
|
"tryAgain": "Try Again"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Products": {
|
"Products": {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
// Create the internationalization middleware
|
// Create the internationalization middleware
|
||||||
const intlMiddleware = createMiddleware({
|
const intlMiddleware = createMiddleware({
|
||||||
@@ -8,31 +7,60 @@ const intlMiddleware = createMiddleware({
|
|||||||
locales: ['en', 'de'],
|
locales: ['en', 'de'],
|
||||||
|
|
||||||
// Used when no locale matches
|
// Used when no locale matches
|
||||||
defaultLocale: 'en'
|
defaultLocale: 'en',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Main middleware that logs all requests
|
|
||||||
export default function middleware(request: NextRequest) {
|
export default function middleware(request: NextRequest) {
|
||||||
const startTime = Date.now();
|
|
||||||
const { method, url, headers } = request;
|
const { method, url, headers } = request;
|
||||||
const userAgent = headers.get('user-agent') || 'unknown';
|
|
||||||
const referer = headers.get('referer') || 'none';
|
|
||||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || 'unknown';
|
|
||||||
|
|
||||||
// Log incoming request
|
// Build header object for logging
|
||||||
console.log(`Incoming request: method=${method} url=${url}`);
|
const headerObj: Record<string, string> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
headerObj[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defensive URL correction for internal container leakage (0.0.0.0, klz-app, localhost)
|
||||||
|
// This prevents hydration mismatches and host poisoning in generated links/metadata.
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const internalHosts = ['0.0.0.0', 'klz-app', 'localhost', '127.0.0.1'];
|
||||||
|
|
||||||
|
let effectiveRequest = request;
|
||||||
|
if (internalHosts.includes(urlObj.hostname)) {
|
||||||
|
const proto = headers.get('x-forwarded-proto') || 'https';
|
||||||
|
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||||
|
const hostHeader =
|
||||||
|
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
||||||
|
const [publicHostname] = hostHeader.split(':');
|
||||||
|
|
||||||
|
urlObj.protocol = proto;
|
||||||
|
urlObj.hostname = publicHostname;
|
||||||
|
urlObj.port = ''; // Explicitly clear internal port (3000)
|
||||||
|
|
||||||
|
effectiveRequest = new NextRequest(urlObj, {
|
||||||
|
headers: request.headers,
|
||||||
|
method: request.method,
|
||||||
|
body: request.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Apply internationalization middleware
|
// Apply internationalization middleware
|
||||||
const response = intlMiddleware(request);
|
const response = intlMiddleware(effectiveRequest);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Request failed: method=${method} url=${url}`, error);
|
console.error(
|
||||||
|
`Request failed: method=${method} url=${url} headers=${JSON.stringify(headerObj)}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Match only internationalized pathnames
|
// Match only internationalized pathnames
|
||||||
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*']
|
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -322,12 +322,12 @@ const nextConfig = {
|
|||||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
|
const umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me');
|
||||||
const glitchtipUrl = process.env.SENTRY_DSN
|
const glitchtipUrl = process.env.SENTRY_DSN
|
||||||
? new URL(process.env.SENTRY_DSN).origin
|
? new URL(process.env.SENTRY_DSN).origin
|
||||||
: 'https://errors.infra.mintel.me';
|
: 'https://errors.infra.mintel.me';
|
||||||
|
|
||||||
const directusUrl = process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
1020
package-lock.json
generated
1020
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^18.0.3",
|
"@directus/sdk": "^18.0.3",
|
||||||
|
"@mintel/mail": "^1.2.3",
|
||||||
"@react-email/components": "^1.0.6",
|
"@react-email/components": "^1.0.6",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@sentry/nextjs": "^8.55.0",
|
"@sentry/nextjs": "^8.55.0",
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
"name": "klz-cables-nextjs",
|
"name": "klz-cables-nextjs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up",
|
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db",
|
||||||
"dev:local": "next dev",
|
"dev:local": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -73,15 +74,15 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:og": "vitest run tests/og-image.test.ts",
|
"test:og": "vitest run tests/og-image.test.ts",
|
||||||
"bootstrap:cms": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||||
"directus:push:staging": "./scripts/sync-directus.sh push staging",
|
"cms:push:staging": "./scripts/sync-directus.sh push staging",
|
||||||
"directus:pull:staging": "./scripts/sync-directus.sh pull staging",
|
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
||||||
"directus:push:testing": "./scripts/sync-directus.sh push testing",
|
"cms:push:testing": "./scripts/sync-directus.sh push testing",
|
||||||
"directus:pull:testing": "./scripts/sync-directus.sh pull testing",
|
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
|
||||||
"directus:push:prod": "./scripts/sync-directus.sh push production",
|
"cms:push:prod": "./scripts/sync-directus.sh push production",
|
||||||
"directus:pull:prod": "./scripts/sync-directus.sh pull production",
|
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
|
||||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -79,33 +79,67 @@ async function main() {
|
|||||||
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
||||||
|
|
||||||
|
// Clean up old reports
|
||||||
|
if (fs.existsSync('.lighthouseci')) {
|
||||||
|
fs.rmSync('.lighthouseci', { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Using a more robust way to execute and capture output
|
// Using a more robust way to execute and capture output
|
||||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert && npx lhci upload`;
|
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
|
||||||
|
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`;
|
||||||
|
|
||||||
console.log(`💻 Executing LHCI...`);
|
console.log(`💻 Executing LHCI...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync(lhciCommand, {
|
execSync(lhciCommand, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['inherit', 'pipe', 'inherit'], // Pipe stdout so we can parse it
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
||||||
|
// We continue to show the table even if assertions failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Summarize Results (Local & Independent)
|
||||||
|
const manifestPath = path.join(process.cwd(), '.lighthouseci', 'manifest.json');
|
||||||
|
if (fs.existsSync(manifestPath)) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
console.log(`\n📊 PageSpeed Summary (FOSS - Local Report):\n`);
|
||||||
|
|
||||||
|
const summaryTable = manifest.map((entry: any) => {
|
||||||
|
const s = entry.summary;
|
||||||
|
return {
|
||||||
|
URL: entry.url.replace(targetUrl, ''),
|
||||||
|
Perf: Math.round(s.performance * 100),
|
||||||
|
Acc: Math.round(s.accessibility * 100),
|
||||||
|
BP: Math.round(s['best-practices'] * 100),
|
||||||
|
SEO: Math.round(s.seo * 100),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(output);
|
console.table(summaryTable);
|
||||||
|
|
||||||
// Extract report URL from LHCI output
|
// Calculate Average
|
||||||
const reportMatch = output.match(
|
const avg = {
|
||||||
/Sent to (https:\/\/storage\.googleapis\.com\/lighthouse-infrastructure\.appspot\.com\/reports\/[^\s]+)/,
|
Perf: Math.round(
|
||||||
);
|
summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length,
|
||||||
if (reportMatch && reportMatch[1]) {
|
),
|
||||||
const reportUrl = reportMatch[1];
|
Acc: Math.round(
|
||||||
console.log(`\n📊 Report URL: ${reportUrl}`);
|
summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length,
|
||||||
fs.writeFileSync('pagespeed-report-url.txt', reportUrl);
|
),
|
||||||
}
|
BP: Math.round(
|
||||||
} catch (err: any) {
|
summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length,
|
||||||
console.error('❌ LHCI execution failed.');
|
),
|
||||||
if (err.stdout) console.log(err.stdout);
|
SEO: Math.round(
|
||||||
if (err.stderr) console.error(err.stderr);
|
summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length,
|
||||||
throw err;
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n📈 Average Scores:`);
|
||||||
|
console.log(` Performance: ${avg.Perf > 90 ? '✅' : '⚠️'} ${avg.Perf}`);
|
||||||
|
console.log(` Accessibility: ${avg.Acc > 90 ? '✅' : '⚠️'} ${avg.Acc}`);
|
||||||
|
console.log(` Best Practices: ${avg.BP > 90 ? '✅' : '⚠️'} ${avg.BP}`);
|
||||||
|
console.log(` SEO: ${avg.SEO > 90 ? '✅' : '⚠️'} ${avg.SEO}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n✨ PageSpeed tests completed successfully!`);
|
console.log(`\n✨ PageSpeed tests completed successfully!`);
|
||||||
|
|||||||
@@ -9,49 +9,65 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
async function setupBranding() {
|
async function setupBranding() {
|
||||||
console.log('🎨 Refining Directus Branding for Premium Website Look...');
|
console.log('🎨 Refining Directus Branding for Premium Website Look...');
|
||||||
|
|
||||||
// 1. Authenticate
|
// 1. Authenticate
|
||||||
await ensureAuthenticated();
|
await ensureAuthenticated();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Upload Assets (MIME FIXED)
|
// 2. Upload Assets (MIME FIXED)
|
||||||
console.log('📤 Re-uploading assets for clean IDs...');
|
console.log('📤 Re-uploading assets for clean IDs...');
|
||||||
|
|
||||||
const getMimeType = (filePath: string) => {
|
const getMimeType = (filePath: string) => {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case '.svg': return 'image/svg+xml';
|
case '.svg':
|
||||||
case '.png': return 'image/png';
|
return 'image/svg+xml';
|
||||||
case '.jpg':
|
case '.png':
|
||||||
case '.jpeg': return 'image/jpeg';
|
return 'image/png';
|
||||||
case '.ico': return 'image/x-icon';
|
case '.jpg':
|
||||||
default: return 'application/octet-stream';
|
case '.jpeg':
|
||||||
}
|
return 'image/jpeg';
|
||||||
};
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const uploadAsset = async (filePath: string, title: string) => {
|
const uploadAsset = async (filePath: string, title: string) => {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.warn(`⚠️ File not found: ${filePath}`);
|
console.warn(`⚠️ File not found: ${filePath}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mimeType = getMimeType(filePath);
|
const mimeType = getMimeType(filePath);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const fileBuffer = fs.readFileSync(filePath);
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
const blob = new Blob([fileBuffer], { type: mimeType });
|
const blob = new Blob([fileBuffer], { type: mimeType });
|
||||||
form.append('file', blob, path.basename(filePath));
|
form.append('file', blob, path.basename(filePath));
|
||||||
form.append('title', title);
|
form.append('title', title);
|
||||||
const res = await client.request(uploadFiles(form));
|
const res = await client.request(uploadFiles(form));
|
||||||
return res.id;
|
return res.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const logoWhiteId = await uploadAsset(path.resolve(__dirname, '../public/logo-white.svg'), 'Logo White');
|
const logoWhiteId = await uploadAsset(
|
||||||
const logoBlueId = await uploadAsset(path.resolve(__dirname, '../public/logo-blue.svg'), 'Logo Blue');
|
path.resolve(__dirname, '../public/logo-white.svg'),
|
||||||
const faviconId = await uploadAsset(path.resolve(__dirname, '../public/favicon.ico'), 'Favicon');
|
'Logo White',
|
||||||
|
);
|
||||||
|
const logoBlueId = await uploadAsset(
|
||||||
|
path.resolve(__dirname, '../public/logo-blue.svg'),
|
||||||
|
'Logo Blue',
|
||||||
|
);
|
||||||
|
const faviconId = await uploadAsset(
|
||||||
|
path.resolve(__dirname, '../public/favicon.ico'),
|
||||||
|
'Favicon',
|
||||||
|
);
|
||||||
|
|
||||||
// Smoother Background SVG
|
// Smoother Background SVG
|
||||||
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
|
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
|
||||||
fs.writeFileSync(bgSvgPath, `<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
fs.writeFileSync(
|
||||||
|
bgSvgPath,
|
||||||
|
`<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="1920" height="1080" fill="#001a4d"/>
|
<rect width="1920" height="1080" fill="#001a4d"/>
|
||||||
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
|
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -60,122 +76,125 @@ async function setupBranding() {
|
|||||||
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
|
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>`);
|
</svg>`,
|
||||||
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
|
);
|
||||||
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
|
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
|
||||||
|
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
|
||||||
|
|
||||||
// 3. Update Settings with "Premium Web" Theme
|
// 3. Update Settings with "Premium Web" Theme
|
||||||
console.log('⚙️ Updating Directus settings...');
|
console.log('⚙️ Updating Directus settings...');
|
||||||
|
|
||||||
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
|
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
|
||||||
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
|
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
|
||||||
const COLOR_SECONDARY = '#003d82';
|
const COLOR_SECONDARY = '#003d82';
|
||||||
|
|
||||||
const cssInjection = `
|
const customCss = `
|
||||||
<style>
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
||||||
|
/* Global Login Styles */
|
||||||
/* Global Login Styles */
|
body, .v-app {
|
||||||
body, .v-app {
|
font-family: 'Inter', sans-serif !important;
|
||||||
font-family: 'Inter', sans-serif !important;
|
-webkit-font-smoothing: antialiased;
|
||||||
-webkit-font-smoothing: antialiased;
|
}
|
||||||
}
|
|
||||||
|
/* Glassmorphism Effect for Login Card */
|
||||||
/* Glassmorphism Effect for Login Card */
|
.public-view .v-card {
|
||||||
.public-view .v-card {
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
background: rgba(255, 255, 255, 0.95) !important;
|
backdrop-filter: blur(20px);
|
||||||
backdrop-filter: blur(20px);
|
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
border-radius: 32px !important;
|
||||||
border-radius: 32px !important;
|
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
||||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
padding: 40px !important;
|
||||||
padding: 40px !important;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.public-view .v-button {
|
.public-view .v-button {
|
||||||
border-radius: full !important;
|
border-radius: 9999px !important;
|
||||||
height: 56px !important;
|
height: 56px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
letter-spacing: -0.01em !important;
|
letter-spacing: -0.01em !important;
|
||||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-view .v-button:hover {
|
.public-view .v-button:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
|
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-view .v-input {
|
.public-view .v-input {
|
||||||
--v-input-border-radius: 12px !important;
|
--v-input-border-radius: 12px !important;
|
||||||
--v-input-background-color: #f8f9fa !important;
|
--v-input-background-color: #f8f9fa !important;
|
||||||
}
|
}
|
||||||
</style>
|
`;
|
||||||
|
|
||||||
|
const publicNote = `
|
||||||
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
|
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
|
||||||
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
|
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
|
||||||
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
|
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await client.request(updateSettings({
|
await client.request(
|
||||||
project_name: 'KLZ Cables',
|
updateSettings({
|
||||||
project_url: 'https://klz-cables.com',
|
project_name: 'KLZ Cables',
|
||||||
project_color: COLOR_ACCENT,
|
project_url: 'https://klz-cables.com',
|
||||||
project_descriptor: 'Sustainable Energy Infrastructure',
|
project_color: COLOR_ACCENT,
|
||||||
project_owner: 'KLZ Cables',
|
project_descriptor: 'Sustainable Energy Infrastructure',
|
||||||
|
|
||||||
// FIXED: Use WHITE logo for the Blue Sidebar
|
// FIXED: Use WHITE logo for the Blue Sidebar
|
||||||
project_logo: logoWhiteId as any,
|
project_logo: logoWhiteId as any,
|
||||||
|
|
||||||
public_foreground: logoWhiteId as any,
|
public_foreground: logoWhiteId as any,
|
||||||
public_background: backgroundId as any,
|
public_background: backgroundId as any,
|
||||||
public_note: cssInjection,
|
public_note: publicNote,
|
||||||
public_favicon: faviconId as any,
|
public_favicon: faviconId as any,
|
||||||
|
custom_css: customCss,
|
||||||
|
|
||||||
// DEEP PREMIUM THEME
|
// DEEP PREMIUM THEME
|
||||||
theme_light_overrides: {
|
theme_light_overrides: {
|
||||||
// Brands
|
// Brands
|
||||||
"primary": COLOR_ACCENT, // Buttons/Actions are GREEN like the website
|
primary: COLOR_ACCENT, // Buttons/Actions are GREEN like the website
|
||||||
"secondary": COLOR_SECONDARY,
|
secondary: COLOR_SECONDARY,
|
||||||
|
|
||||||
// Content Area
|
// Content Area
|
||||||
"background": "#f1f3f7",
|
background: '#f1f3f7',
|
||||||
"backgroundNormal": "#ffffff",
|
backgroundNormal: '#ffffff',
|
||||||
"backgroundAccent": "#eef2ff",
|
backgroundAccent: '#eef2ff',
|
||||||
|
|
||||||
// Sidebar Branding
|
// Sidebar Branding
|
||||||
"navigationBackground": COLOR_PRIMARY,
|
navigationBackground: COLOR_PRIMARY,
|
||||||
"navigationForeground": "#ffffff",
|
navigationForeground: '#ffffff',
|
||||||
"navigationBackgroundHover": "rgba(255,255,255,0.05)",
|
navigationBackgroundHover: 'rgba(255,255,255,0.05)',
|
||||||
"navigationForegroundHover": "#ffffff",
|
navigationForegroundHover: '#ffffff',
|
||||||
"navigationBackgroundActive": "rgba(130, 237, 32, 0.15)", // Subtle Green highlight
|
navigationBackgroundActive: 'rgba(130, 237, 32, 0.15)', // Subtle Green highlight
|
||||||
"navigationForegroundActive": COLOR_ACCENT, // Active item is GREEN
|
navigationForegroundActive: COLOR_ACCENT, // Active item is GREEN
|
||||||
|
|
||||||
// Module Bar (Thin far left)
|
// Module Bar (Thin far left)
|
||||||
"moduleBarBackground": "#000d26",
|
moduleBarBackground: '#000d26',
|
||||||
"moduleBarForeground": "#ffffff",
|
moduleBarForeground: '#ffffff',
|
||||||
"moduleBarForegroundActive": COLOR_ACCENT,
|
moduleBarForegroundActive: COLOR_ACCENT,
|
||||||
|
|
||||||
// UI Standards
|
// UI Standards
|
||||||
"borderRadius": "16px", // Larger radius for modern feel
|
borderRadius: '16px', // Larger radius for modern feel
|
||||||
"borderWidth": "1px",
|
borderWidth: '1px',
|
||||||
"borderColor": "#e2e8f0",
|
borderColor: '#e2e8f0',
|
||||||
"formFieldHeight": "48px" // Touch-target height
|
formFieldHeight: '48px', // Touch-target height
|
||||||
} as any,
|
} as any,
|
||||||
|
|
||||||
theme_dark_overrides: {
|
theme_dark_overrides: {
|
||||||
"primary": COLOR_ACCENT,
|
primary: COLOR_ACCENT,
|
||||||
"background": "#0a0a0a",
|
background: '#0a0a0a',
|
||||||
"navigationBackground": "#000000",
|
navigationBackground: '#000000',
|
||||||
"moduleBarBackground": COLOR_PRIMARY,
|
moduleBarBackground: COLOR_PRIMARY,
|
||||||
"borderRadius": "16px",
|
borderRadius: '16px',
|
||||||
"formFieldHeight": "48px"
|
formFieldHeight: '48px',
|
||||||
} as any
|
} as any,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
console.log('✨ Premium Theme applied successfully!');
|
console.log('✨ Premium Theme applied successfully!');
|
||||||
|
} catch (error: any) {
|
||||||
} catch (error: any) {
|
console.error('❌ Error:', JSON.stringify(error, null, 2));
|
||||||
console.error('❌ Error:', JSON.stringify(error, null, 2));
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupBranding();
|
setupBranding();
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ case $ENV in
|
|||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
PROJECT_NAME="klz-cables-prod"
|
PROJECT_NAME="klz-cables-prod"
|
||||||
|
# Fallback to older project name if prod-specific one isn't found later in the script
|
||||||
|
OLD_PROJECT_NAME="klz-cablescom"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -58,6 +60,7 @@ if [ "$ACTION" == "push" ]; then
|
|||||||
|
|
||||||
# 1. DB Dump
|
# 1. DB Dump
|
||||||
echo "📦 Dumping local database..."
|
echo "📦 Dumping local database..."
|
||||||
|
# Note: we use --no-owner --no-privileges to ensure restore works on remote with different user setup
|
||||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
||||||
|
|
||||||
# 2. Upload Dump
|
# 2. Upload Dump
|
||||||
@@ -67,10 +70,21 @@ if [ "$ACTION" == "push" ]; then
|
|||||||
# 3. Restore on Remote
|
# 3. Restore on Remote
|
||||||
echo "🔄 Restoring dump on $ENV..."
|
echo "🔄 Restoring dump on $ENV..."
|
||||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||||
|
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||||
|
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||||
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||||
echo "❌ Remote $ENV-db container not found!"
|
echo "❌ Remote $ENV-db container not found!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Wipe remote DB clean before restore to avoid constraint errors
|
||||||
|
echo "🧹 Wiping remote database schema..."
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
|
||||||
|
|
||||||
|
echo "⚡ Restoring database..."
|
||||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
# 4. Sync Uploads
|
# 4. Sync Uploads
|
||||||
@@ -83,6 +97,10 @@ if [ "$ACTION" == "push" ]; then
|
|||||||
rm dump.sql
|
rm dump.sql
|
||||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
# 5. Restart Directus to trigger migrations and refresh schema cache
|
||||||
|
echo "🔄 Restarting remote Directus to apply migrations..."
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||||
|
|
||||||
echo "✨ Push to $ENV complete!"
|
echo "✨ Push to $ENV complete!"
|
||||||
|
|
||||||
elif [ "$ACTION" == "pull" ]; then
|
elif [ "$ACTION" == "pull" ]; then
|
||||||
@@ -91,6 +109,11 @@ elif [ "$ACTION" == "pull" ]; then
|
|||||||
# 1. DB Dump on Remote
|
# 1. DB Dump on Remote
|
||||||
echo "📦 Dumping remote database ($ENV)..."
|
echo "📦 Dumping remote database ($ENV)..."
|
||||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||||
|
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||||
|
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||||
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||||
echo "❌ Remote $ENV-db container not found!"
|
echo "❌ Remote $ENV-db container not found!"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -101,8 +124,11 @@ elif [ "$ACTION" == "pull" ]; then
|
|||||||
echo "📥 Downloading dump..."
|
echo "📥 Downloading dump..."
|
||||||
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
||||||
|
|
||||||
# 3. Restore Locally
|
# Wipe local DB clean before restore to avoid constraint errors
|
||||||
echo "🔄 Restoring dump locally..."
|
echo "🧹 Wiping local database schema..."
|
||||||
|
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||||
|
|
||||||
|
echo "⚡ Restoring database locally..."
|
||||||
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
||||||
|
|
||||||
# 4. Sync Uploads
|
# 4. Sync Uploads
|
||||||
|
|||||||
59
scripts/update_ampacity.py
Normal file
59
scripts/update_ampacity.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import openpyxl
|
||||||
|
|
||||||
|
def update_excel_ampacity(file_path, headers_row_idx, ampacity_cols_identifiers, target_cross_section="1x1200/35"):
|
||||||
|
print(f"Updating {file_path}...")
|
||||||
|
wb = openpyxl.load_workbook(file_path)
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# openpyxl is 1-indexed for rows and columns
|
||||||
|
headers = [cell.value for cell in ws[headers_row_idx]]
|
||||||
|
|
||||||
|
# Identify column indices for ampacity (0-indexed locally for easier row access)
|
||||||
|
col_indices = []
|
||||||
|
for identifier in ampacity_cols_identifiers:
|
||||||
|
if isinstance(identifier, int):
|
||||||
|
col_indices.append(identifier)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# list.index returns 0-indexed position
|
||||||
|
col_indices.append(headers.index(identifier))
|
||||||
|
except ValueError:
|
||||||
|
print(f"Warning: Could not find column '{identifier}' in {file_path}")
|
||||||
|
|
||||||
|
# Find row index for "Number of cores and cross-section" or use index 8
|
||||||
|
cs_col_idx = 8
|
||||||
|
try:
|
||||||
|
cs_col_idx = headers.index("Number of cores and cross-section")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rows_updated = 0
|
||||||
|
# ws.iter_rows returns 1-indexed rows
|
||||||
|
for row in ws.iter_rows(min_row=headers_row_idx + 1):
|
||||||
|
# row is a tuple of cells, so row[cs_col_idx] is 0-indexed access to the tuple
|
||||||
|
if str(row[cs_col_idx].value).strip() == target_cross_section:
|
||||||
|
for col_idx in col_indices:
|
||||||
|
row[col_idx].value = "On Request"
|
||||||
|
rows_updated += 1
|
||||||
|
|
||||||
|
wb.save(file_path)
|
||||||
|
print(f"Updated {rows_updated} rows in {file_path}")
|
||||||
|
|
||||||
|
# File 1: medium-voltage-KM.xlsx
|
||||||
|
update_excel_ampacity(
|
||||||
|
'data/excel/medium-voltage-KM.xlsx',
|
||||||
|
1, # Headers are in first row (1-indexed)
|
||||||
|
[
|
||||||
|
'Current ratings in air, trefoil*',
|
||||||
|
'Current ratings in air, flat*',
|
||||||
|
'Current ratings in ground, trefoil*',
|
||||||
|
'Current ratings in ground, flat*'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# File 2: medium-voltage-KM 170126.xlsx
|
||||||
|
update_excel_ampacity(
|
||||||
|
'data/excel/medium-voltage-KM 170126.xlsx',
|
||||||
|
1, # Indices 39 and 41 were from a 0-indexed JSON representation
|
||||||
|
[39, 41]
|
||||||
|
)
|
||||||
87
scripts/update_excel.py
Normal file
87
scripts/update_excel.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import openpyxl
|
||||||
|
|
||||||
|
excel_path = 'data/excel/medium-voltage-KM.xlsx'
|
||||||
|
wb = openpyxl.load_workbook(excel_path)
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# Technical data for 1x1200RM/35
|
||||||
|
new_rows_data = [
|
||||||
|
{
|
||||||
|
"Rated voltage": "6/10",
|
||||||
|
"Test voltage": 21,
|
||||||
|
"Nominal insulation thickness": 3.4,
|
||||||
|
"Diameter over insulation (approx.)": 48.5,
|
||||||
|
"Minimum sheath thickness": 2.1,
|
||||||
|
"Outer diameter (approx.)": 59,
|
||||||
|
"Bending radius (min.)": 885,
|
||||||
|
"Weight (approx.)": 4800,
|
||||||
|
"Capacitance (approx.)": 0.95,
|
||||||
|
"Inductance, trefoil (approx.)": 0.24,
|
||||||
|
"Inductance in air, flat (approx.) 1": 0.40,
|
||||||
|
"Inductance in ground, flat (approx.) 1": 0.42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Rated voltage": "12/20",
|
||||||
|
"Test voltage": 42,
|
||||||
|
"Nominal insulation thickness": 5.5,
|
||||||
|
"Diameter over insulation (approx.)": 52.3,
|
||||||
|
"Minimum sheath thickness": 2.1,
|
||||||
|
"Outer diameter (approx.)": 66,
|
||||||
|
"Bending radius (min.)": 990,
|
||||||
|
"Weight (approx.)": 5200,
|
||||||
|
"Capacitance (approx.)": 1.05,
|
||||||
|
"Inductance, trefoil (approx.)": 0.23,
|
||||||
|
"Inductance in air, flat (approx.) 1": 0.43,
|
||||||
|
"Inductance in ground, flat (approx.) 1": 0.45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Rated voltage": "18/30",
|
||||||
|
"Test voltage": 63,
|
||||||
|
"Nominal insulation thickness": 8.0,
|
||||||
|
"Diameter over insulation (approx.)": 57.5,
|
||||||
|
"Minimum sheath thickness": 2.4,
|
||||||
|
"Outer diameter (approx.)": 71,
|
||||||
|
"Bending radius (min.)": 1065,
|
||||||
|
"Weight (approx.)": 5900,
|
||||||
|
"Capacitance (approx.)": 1.15,
|
||||||
|
"Inductance, trefoil (approx.)": 0.22,
|
||||||
|
"Inductance in air, flat (approx.) 1": 0.45,
|
||||||
|
"Inductance in ground, flat (approx.) 1": 0.47,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find a template row for NA2XS(F)2Y
|
||||||
|
template_row = None
|
||||||
|
headers = [cell.value for cell in ws[1]]
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=3, values_only=True):
|
||||||
|
if row[0] == 'NA2XS(F)2Y':
|
||||||
|
template_row = list(row)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not template_row:
|
||||||
|
print("Error: Could not find template row for NA2XS(F)2Y")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Function to update template with new values
|
||||||
|
def create_row(template, updates, headers):
|
||||||
|
new_row = template[:]
|
||||||
|
# Change "Number of cores and cross-section"
|
||||||
|
cs_idx = headers.index("Number of cores and cross-section")
|
||||||
|
new_row[cs_idx] = "1x1200/35"
|
||||||
|
|
||||||
|
# Apply specific updates
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key in headers:
|
||||||
|
idx = headers.index(key)
|
||||||
|
new_row[idx] = value
|
||||||
|
return new_row
|
||||||
|
|
||||||
|
# Append new rows
|
||||||
|
for data in new_rows_data:
|
||||||
|
new_row_values = create_row(template_row, data, headers)
|
||||||
|
ws.append(new_row_values)
|
||||||
|
print(f"Added row for {data['Rated voltage']} kV")
|
||||||
|
|
||||||
|
wb.save(excel_path)
|
||||||
|
print("Excel file updated successfully.")
|
||||||
120
scripts/update_excel_v2.py
Normal file
120
scripts/update_excel_v2.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import openpyxl
|
||||||
|
|
||||||
|
excel_path = 'data/excel/medium-voltage-KM 170126.xlsx'
|
||||||
|
wb = openpyxl.load_workbook(excel_path)
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# Technical data for 1x1200RM/35
|
||||||
|
# Indices based on Row 2 (Units) and Row 1
|
||||||
|
# Index 0: Part Number
|
||||||
|
# Index 8: Querschnitt
|
||||||
|
# Index 9: Rated voltage
|
||||||
|
# Index 10: Test voltage
|
||||||
|
# Index 23: LD mm
|
||||||
|
# Index 24: ID mm
|
||||||
|
# Index 25: DI mm
|
||||||
|
# Index 26: MWD mm
|
||||||
|
# Index 27: AD mm
|
||||||
|
# Index 28: BR
|
||||||
|
# Index 29: G kg
|
||||||
|
# Index 30: RI Ohm
|
||||||
|
# Index 31: Cap
|
||||||
|
# Index 32: Inductance trefoil
|
||||||
|
# Index 35: BK
|
||||||
|
# Index 39: SBL 30
|
||||||
|
# Index 41: SBE 20
|
||||||
|
|
||||||
|
new_rows_data = [
|
||||||
|
{
|
||||||
|
"voltage": "6/10",
|
||||||
|
"test_v": 21,
|
||||||
|
"ld": 41.5,
|
||||||
|
"id": 3.4,
|
||||||
|
"di": 48.5,
|
||||||
|
"mwd": 2.1,
|
||||||
|
"ad": 59,
|
||||||
|
"br": 885,
|
||||||
|
"g": 4800,
|
||||||
|
"ri": 0.0247,
|
||||||
|
"cap": 0.95,
|
||||||
|
"ind": 0.24,
|
||||||
|
"bk": 113,
|
||||||
|
"sbl": 1300,
|
||||||
|
"sbe": 933
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voltage": "12/20",
|
||||||
|
"test_v": 42,
|
||||||
|
"ld": 41.5,
|
||||||
|
"id": 5.5,
|
||||||
|
"di": 52.3,
|
||||||
|
"mwd": 2.1,
|
||||||
|
"ad": 66,
|
||||||
|
"br": 990,
|
||||||
|
"g": 5200,
|
||||||
|
"ri": 0.0247,
|
||||||
|
"cap": 1.05,
|
||||||
|
"ind": 0.23,
|
||||||
|
"bk": 113,
|
||||||
|
"sbl": 1200,
|
||||||
|
"sbe": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"voltage": "18/30",
|
||||||
|
"test_v": 63,
|
||||||
|
"ld": 41.5,
|
||||||
|
"id": 8.0,
|
||||||
|
"di": 57.5,
|
||||||
|
"mwd": 2.4,
|
||||||
|
"ad": 71,
|
||||||
|
"br": 1065,
|
||||||
|
"g": 5900,
|
||||||
|
"ri": 0.0247,
|
||||||
|
"cap": 1.15,
|
||||||
|
"ind": 0.22,
|
||||||
|
"bk": 113,
|
||||||
|
"sbl": 1300,
|
||||||
|
"sbe": 950
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find a template row for NA2XS(F)2Y
|
||||||
|
template_row = None
|
||||||
|
for row in ws.iter_rows(min_row=3, values_only=True):
|
||||||
|
if row[0] == 'NA2XS(F)2Y' and row[9] == '6/10':
|
||||||
|
template_row = list(row)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not template_row:
|
||||||
|
print("Error: Could not find template row for NA2XS(F)2Y")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Function to update template with new values
|
||||||
|
def create_row(template, data):
|
||||||
|
new_row = template[:]
|
||||||
|
new_row[8] = "1x1200/35"
|
||||||
|
new_row[9] = data["voltage"]
|
||||||
|
new_row[10] = data["test_v"]
|
||||||
|
new_row[23] = data["ld"]
|
||||||
|
new_row[24] = data["id"]
|
||||||
|
new_row[25] = data["di"]
|
||||||
|
new_row[26] = data["mwd"]
|
||||||
|
new_row[27] = data["ad"]
|
||||||
|
new_row[28] = data["br"]
|
||||||
|
new_row[29] = data["g"]
|
||||||
|
new_row[30] = data["ri"]
|
||||||
|
new_row[31] = data["cap"]
|
||||||
|
new_row[32] = data["ind"]
|
||||||
|
new_row[35] = data["bk"]
|
||||||
|
new_row[39] = data["sbl"]
|
||||||
|
new_row[41] = data["sbe"]
|
||||||
|
return new_row
|
||||||
|
|
||||||
|
# Append new rows
|
||||||
|
for data in new_rows_data:
|
||||||
|
new_row_values = create_row(template_row, data)
|
||||||
|
ws.append(new_row_values)
|
||||||
|
print(f"Added row for {data['voltage']} kV")
|
||||||
|
|
||||||
|
wb.save(excel_path)
|
||||||
|
print("Excel file updated successfully.")
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--font-sans:
|
||||||
|
'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
Arial, sans-serif;
|
||||||
--font-heading: 'Inter', system-ui, sans-serif;
|
--font-heading: 'Inter', system-ui, sans-serif;
|
||||||
--font-body: 'Inter', system-ui, sans-serif;
|
--font-body: 'Inter', system-ui, sans-serif;
|
||||||
|
|
||||||
@@ -30,43 +32,82 @@
|
|||||||
--color-success: #10b981;
|
--color-success: #10b981;
|
||||||
--color-warning: #f59e0b;
|
--color-warning: #f59e0b;
|
||||||
--color-danger: #ef4444;
|
--color-danger: #ef4444;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
--color-info: #3b82f6;
|
--color-info: #3b82f6;
|
||||||
|
|
||||||
--animate-fade-in: fade-in 0.5s ease-out;
|
--animate-fade-in: fade-in 0.5s ease-out;
|
||||||
--animate-slide-up: slide-up 0.6s ease-out;
|
--animate-slide-up: slide-up 0.6s ease-out;
|
||||||
--animate-slow-zoom: slow-zoom 20s linear infinite;
|
--animate-slow-zoom: slow-zoom 20s linear infinite;
|
||||||
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
|
||||||
|
cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||||
|
|
||||||
@keyframes gradient-x {
|
@keyframes gradient-x {
|
||||||
0%, 100% { background-position: 0% 50%; }
|
0%,
|
||||||
50% { background-position: 100% 50%; }
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes slow-zoom {
|
@keyframes slow-zoom {
|
||||||
from { transform: scale(1); }
|
from {
|
||||||
to { transform: scale(1.1); }
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes reveal {
|
@keyframes reveal {
|
||||||
from { opacity: 0; transform: translateY(20px); filter: blur(8px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes slight-fade-in-from-bottom {
|
@keyframes slight-fade-in-from-bottom {
|
||||||
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
.bg-primary a, .bg-primary-dark a {
|
.bg-primary a,
|
||||||
|
.bg-primary-dark a {
|
||||||
@apply text-white/90 hover:text-white transition-colors;
|
@apply text-white/90 hover:text-white transition-colors;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@@ -76,63 +117,81 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
@apply font-heading font-bold tracking-tight;
|
@apply font-heading font-bold tracking-tight;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced Mobile-first typography hierarchy with fluid sizing */
|
/* Enhanced Mobile-first typography hierarchy with fluid sizing */
|
||||||
h1 { @apply text-3xl md:text-5xl lg:text-6xl leading-[1.1]; }
|
h1 {
|
||||||
h2 { @apply text-2xl md:text-4xl lg:text-5xl leading-[1.2]; }
|
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
|
||||||
h3 { @apply text-xl md:text-2xl lg:text-3xl leading-[1.3]; }
|
}
|
||||||
h4 { @apply text-lg md:text-xl lg:text-2xl leading-[1.4]; }
|
h2 {
|
||||||
h5 { @apply text-base md:text-lg leading-[1.5]; }
|
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
|
||||||
h6 { @apply text-sm md:text-base leading-[1.6]; }
|
}
|
||||||
|
h3 {
|
||||||
|
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
@apply text-lg md:text-xl lg:text-2xl leading-[1.4];
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
@apply text-base md:text-lg leading-[1.5];
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
@apply text-sm md:text-base leading-[1.6];
|
||||||
|
}
|
||||||
|
|
||||||
/* Paragraph and text styles */
|
/* Paragraph and text styles */
|
||||||
p {
|
p {
|
||||||
@apply mb-4 leading-relaxed;
|
@apply mb-4 leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Link styles */
|
/* Link styles */
|
||||||
a {
|
a {
|
||||||
@apply no-underline transition-all duration-200;
|
@apply no-underline transition-all duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List styles */
|
/* List styles */
|
||||||
ul, ol {
|
ul,
|
||||||
|
ol {
|
||||||
@apply my-4 ml-6;
|
@apply my-4 ml-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
@apply mb-2 leading-relaxed;
|
@apply mb-2 leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small text */
|
/* Small text */
|
||||||
small {
|
small {
|
||||||
@apply text-sm md:text-base;
|
@apply text-sm md:text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Strong and emphasis */
|
/* Strong and emphasis */
|
||||||
strong {
|
strong {
|
||||||
@apply font-bold;
|
@apply font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
em {
|
em {
|
||||||
@apply italic;
|
@apply italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blockquote */
|
/* Blockquote */
|
||||||
blockquote {
|
blockquote {
|
||||||
@apply border-l-4 pl-6 my-6 italic;
|
@apply border-l-4 pl-6 my-6 italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code */
|
/* Code */
|
||||||
code {
|
code {
|
||||||
@apply px-2 py-1 rounded font-mono text-sm;
|
@apply px-2 py-1 rounded font-mono text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal rule */
|
/* Horizontal rule */
|
||||||
hr {
|
hr {
|
||||||
@apply my-8;
|
@apply my-8;
|
||||||
@@ -177,7 +236,7 @@
|
|||||||
opacity 0.6s ease-out,
|
opacity 0.6s ease-out,
|
||||||
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
&.is-visible {
|
&.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
102
varnish/default.vcl
Normal file
102
varnish/default.vcl
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
vcl 4.1;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
|
||||||
|
probe default_probe {
|
||||||
|
.url = "/health";
|
||||||
|
.timeout = 2s;
|
||||||
|
.interval = 5s;
|
||||||
|
.window = 5;
|
||||||
|
.threshold = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
backend default {
|
||||||
|
.host = "klz-app";
|
||||||
|
.port = "3000";
|
||||||
|
.connect_timeout = 10s;
|
||||||
|
.first_byte_timeout = 300s;
|
||||||
|
.between_bytes_timeout = 10s;
|
||||||
|
.probe = default_probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
acl purge {
|
||||||
|
"localhost";
|
||||||
|
"127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub vcl_recv {
|
||||||
|
# Only allow PURGE from the ACL
|
||||||
|
if (req.method == "PURGE") {
|
||||||
|
if (!client.ip ~ purge) {
|
||||||
|
return (synth(405, "Not allowed."));
|
||||||
|
}
|
||||||
|
return (purge);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only cache GET and HEAD requests
|
||||||
|
if (req.method != "GET" && req.method != "HEAD") {
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bypass cache for Directus and CMS proxy
|
||||||
|
if (req.url ~ "^/directus" || req.url ~ "^/admin" || req.url ~ "^/cms") {
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bypass cache for Next.js preview mode / health checks
|
||||||
|
if (req.url ~ "^/api/preview" || req.url ~ "^/health") {
|
||||||
|
return (pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove all cookies for static files to improve cache hits
|
||||||
|
if (req.url ~ "\.(png|gif|jpg|jpeg|svg|ico|webp|js|css|woff|woff2|otf|ttf)$") {
|
||||||
|
unset req.http.Cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize Cookies: Remove tracking cookies that don't affect page content
|
||||||
|
# This keeps cookies like NEXT_LOCALE or AUTH cookies if needed, but strips others
|
||||||
|
if (req.http.Cookie) {
|
||||||
|
# Strip Google Analytics cookies
|
||||||
|
set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(__utm.|_ga.|_gid.|_gat)(=[^;]*)?", "");
|
||||||
|
# Strip empty cookies
|
||||||
|
set req.http.Cookie = regsub(req.http.Cookie, "^;\s*", "");
|
||||||
|
if (req.http.Cookie ~ "^\s*$") {
|
||||||
|
unset req.http.Cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub vcl_backend_response {
|
||||||
|
# Cache static assets for a long time
|
||||||
|
if (bereq.url ~ "\.(png|gif|jpg|jpeg|svg|ico|webp|js|css|woff|woff2|otf|ttf)$") {
|
||||||
|
set beresp.ttl = 1w;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Respect Cache-Control from Next.js
|
||||||
|
# If the response should not be cached, Next.js will usually send Cache-Control: no-cache, no-store, etc.
|
||||||
|
if (beresp.http.Cache-Control ~ "private" ||
|
||||||
|
beresp.http.Cache-Control ~ "no-cache" ||
|
||||||
|
beresp.http.Cache-Control ~ "no-store") {
|
||||||
|
set beresp.uncacheable = true;
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set a default TTL if none is provided by the backend
|
||||||
|
if (beresp.ttl <= 0s) {
|
||||||
|
set beresp.ttl = 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (deliver);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub vcl_deliver {
|
||||||
|
# Add a debug header to show if it was a hit or miss
|
||||||
|
if (obj.hits > 0) {
|
||||||
|
set resp.http.X-Cache = "HIT";
|
||||||
|
set resp.http.X-Cache-Hits = obj.hits;
|
||||||
|
} else {
|
||||||
|
set resp.http.X-Cache = "MISS";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user