Compare commits
46 Commits
v1.0.0-rc.
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 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 |
15
.env
15
.env
@@ -1,16 +1,10 @@
|
|||||||
# 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
|
|
||||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
||||||
# WooCommerce & WordPress
|
|
||||||
WOOCOMMERCE_URL=https://klz-cables.com
|
|
||||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
|
||||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
|
||||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -26,10 +20,15 @@ DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
|||||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
|
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_DB_NAME=directus
|
DIRECTUS_DB_NAME=directus
|
||||||
DIRECTUS_DB_USER=directus
|
DIRECTUS_DB_USER=directus
|
||||||
# Local Development
|
# Local Development
|
||||||
PROJECT_NAME=klz-cables
|
PROJECT_NAME=klz-cables
|
||||||
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
TRAEFIK_HOST=klz.localhost
|
TRAEFIK_HOST=klz.localhost
|
||||||
DIRECTUS_HOST=cms.klz.localhost
|
DIRECTUS_HOST=cms.klz.localhost
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
|
COOKIE_DOMAIN=localhost
|
||||||
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
|||||||
13
.env.example
13
.env.example
@@ -10,18 +10,18 @@
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
DIRECTUS_PORT=8055
|
||||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||||
NEXT_PUBLIC_TARGET=development
|
|
||||||
# TARGET is used server-side
|
|
||||||
TARGET=development
|
TARGET=development
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# Optional: Leave empty to disable analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
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)
|
||||||
@@ -57,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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
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=
|
||||||
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||||
MAIL_RECIPIENTS=info@klz-cables.com
|
MAIL_RECIPIENTS=info@klz-cables.com
|
||||||
|
|
||||||
# Strapi
|
|
||||||
STRAPI_DATABASE_NAME=strapi
|
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
|
||||||
STRAPI_DATABASE_PASSWORD=
|
|
||||||
APP_KEYS=
|
|
||||||
API_TOKEN_SALT=
|
|
||||||
ADMIN_JWT_SECRET=
|
|
||||||
TRANSFER_TOKEN_SALT=
|
|
||||||
JWT_SECRET=
|
|
||||||
|
|
||||||
# Varnish Cache Size (optional)
|
# Varnish Cache Size (optional)
|
||||||
VARNISH_CACHE_SIZE=256m
|
VARNISH_CACHE_SIZE=256m
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
.next/
|
|
||||||
node_modules/
|
|
||||||
reference/
|
|
||||||
public/
|
|
||||||
dist/
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"argsIgnorePattern": "^_"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"prefer-const": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"@next/next/no-img-element": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,8 @@ jobs:
|
|||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
env_file: ${{ steps.determine.outputs.env_file }}
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
|
traefik_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
|
||||||
|
primary_host: ${{ steps.determine.outputs.primary_host }}
|
||||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
directus_host: ${{ steps.determine.outputs.directus_host }}
|
||||||
@@ -38,7 +40,6 @@ 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 }}
|
||||||
gatekeeper_changed: ${{ steps.changes.outputs.gatekeeper_changed }}
|
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -54,22 +55,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: 🔍 Check for Gatekeeper changes
|
|
||||||
id: changes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
|
||||||
if git diff --quiet HEAD~1 HEAD -- gatekeeper; then
|
|
||||||
echo "gatekeeper_changed=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "ℹ️ No changes in gatekeeper/"
|
|
||||||
else
|
|
||||||
echo "gatekeeper_changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "⚠️ Changes detected in gatekeeper/"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "gatekeeper_changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "🆕 First commit or no history, building gatekeeper."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 🔍 Environment & Version ermitteln
|
- name: 🔍 Environment & Version ermitteln
|
||||||
id: determine
|
id: determine
|
||||||
@@ -89,10 +74,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"
|
||||||
@@ -103,10 +88,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"
|
||||||
@@ -115,10 +100,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)"
|
||||||
@@ -132,11 +117,23 @@ jobs:
|
|||||||
TARGET="skip"
|
TARGET="skip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
|
# Multi-domain: Host(`a.com`) || Host(`b.com`)
|
||||||
|
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||||
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
|
else
|
||||||
|
# Single domain: Host(`domain.com`)
|
||||||
|
TRAEFIK_HOST_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||||
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
echo "image_tag=$IMAGE_TAG"
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$TRAEFIK_HOST"
|
echo "traefik_host=$TRAEFIK_HOST"
|
||||||
|
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
|
||||||
|
echo "primary_host=$PRIMARY_HOST"
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||||
echo "directus_url=$DIRECTUS_URL"
|
echo "directus_url=$DIRECTUS_URL"
|
||||||
echo "directus_host=$DIRECTUS_HOST"
|
echo "directus_host=$DIRECTUS_HOST"
|
||||||
@@ -170,7 +167,7 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- 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'
|
||||||
@@ -215,16 +212,12 @@ jobs:
|
|||||||
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_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) }}
|
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
run: |
|
run: |
|
||||||
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_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
|
||||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
--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 \
|
||||||
@@ -232,58 +225,12 @@ jobs:
|
|||||||
--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 .
|
||||||
|
|
||||||
build-gatekeeper:
|
|
||||||
name: 🏗️ Build Gatekeeper
|
|
||||||
needs: prepare
|
|
||||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🐳 Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
|
|
||||||
- name: 🏗️ Gatekeeper bauen & pushen
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
|
||||||
CHG: ${{ needs.prepare.outputs.gatekeeper_changed }}
|
|
||||||
run: |
|
|
||||||
if [ "$CHG" == "true" ]; then
|
|
||||||
echo "🏗️ Building Gatekeeper (Changes detected)..."
|
|
||||||
docker buildx build \
|
|
||||||
--pull \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
|
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables-gatekeeper:buildcache \
|
|
||||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables-gatekeeper:buildcache,mode=max \
|
|
||||||
--push ./gatekeeper
|
|
||||||
else
|
|
||||||
echo "⏩ Skipping build, just re-tagging existing image..."
|
|
||||||
# Fast-track: tag the latest (or buildcache) as the new version
|
|
||||||
# We use buildx with cache but without rebuild triggers - it's near instant
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
|
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables-gatekeeper:buildcache \
|
|
||||||
--push ./gatekeeper
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy via SSH
|
# JOB 4: Deploy via SSH
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
needs: [prepare, build-app, build-gatekeeper, 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:
|
container:
|
||||||
@@ -292,28 +239,28 @@ jobs:
|
|||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_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) }}
|
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 }}
|
||||||
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
|
||||||
@@ -322,6 +269,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
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"
|
||||||
|
|
||||||
@@ -330,15 +278,19 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
|
# Determine dynamic values before writing the file
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
cat > /tmp/klz-cables.env << EOF
|
||||||
# Generated by CI - $TARGET - $(date -u)
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
NODE_ENV=production
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
NEXT_PUBLIC_TARGET=$TARGET
|
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
LOG_LEVEL=$LOG_LEVEL
|
||||||
MAIL_HOST=$MAIL_HOST
|
MAIL_HOST=$MAIL_HOST
|
||||||
MAIL_PORT=$MAIL_PORT
|
MAIL_PORT=$MAIL_PORT
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
@@ -362,20 +314,41 @@ jobs:
|
|||||||
|
|
||||||
TARGET=$TARGET
|
TARGET=$TARGET
|
||||||
SENTRY_ENVIRONMENT=$TARGET
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
ENV_FILE=$ENV_FILE
|
|
||||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Append complex variables that contain backticks using printf to avoid shell expansion hits
|
||||||
|
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
|
||||||
|
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
|
||||||
|
|
||||||
|
# 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 \
|
||||||
|
/home/deploy/sites/klz-cables.com/directus/schema
|
||||||
|
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 directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
|
||||||
|
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
|
||||||
@@ -392,6 +365,23 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "→ Applying Directus Schema Snapshot..."
|
||||||
|
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
|
||||||
|
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||||
|
else
|
||||||
|
echo "ℹ️ No snapshot.yaml found, skipping schema apply."
|
||||||
|
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
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -420,7 +410,7 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- 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: |
|
||||||
@@ -496,7 +486,7 @@ jobs:
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notifications
|
name: 🔔 Notifications
|
||||||
needs: [prepare, qa, build-app, build-gatekeeper, deploy, pagespeed]
|
needs: [prepare, qa, build-app, deploy, pagespeed]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -527,7 +517,6 @@ jobs:
|
|||||||
needs.prepare.result == 'failure' ||
|
needs.prepare.result == 'failure' ||
|
||||||
needs.qa.result == 'failure' ||
|
needs.qa.result == 'failure' ||
|
||||||
needs.build-app.result == 'failure' ||
|
needs.build-app.result == 'failure' ||
|
||||||
needs.build-gatekeeper.result == 'failure' ||
|
|
||||||
needs.deploy.result == 'failure' ||
|
needs.deploy.result == 'failure' ||
|
||||||
needs.pagespeed.result == 'failure'
|
needs.pagespeed.result == 'failure'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ node_modules
|
|||||||
# Directus
|
# Directus
|
||||||
directus/uploads
|
directus/uploads
|
||||||
!directus/extensions/
|
!directus/extensions/
|
||||||
|
!directus/schema/
|
||||||
|
!directus/migrations/
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const buildEslintCommand = (filenames) =>
|
const buildEslintCommand = (filenames) =>
|
||||||
`next lint --fix --file ${filenames
|
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
.map((f) => path.relative(process.cwd(), f))
|
|
||||||
.join(' --file ')}`;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Sheet 1
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,237 +0,0 @@
|
|||||||
# Analytics Migration Complete ✅
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics (WordPress) to Umami.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
**Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- Converts Independent Analytics CSV to Umami format
|
|
||||||
- Supports 3 output formats: JSON (API), SQL (database), API payload
|
|
||||||
- Preserves page view counts and average duration data
|
|
||||||
|
|
||||||
### 2. Deployment Script
|
|
||||||
**Location:** `scripts/deploy-analytics-to-umami.sh`
|
|
||||||
- Tailored for your server setup (`deploy@alpha.mintel.me`)
|
|
||||||
- Copies files to your Umami server
|
|
||||||
- Provides import instructions for your specific environment
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
**Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 page view events
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
**Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL statements
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
- **Use:** Direct database import
|
|
||||||
|
|
||||||
### 4. Documentation
|
|
||||||
|
|
||||||
**Location:** `scripts/README-migration.md`
|
|
||||||
- Step-by-step migration guide
|
|
||||||
- Prerequisites and setup instructions
|
|
||||||
- Import methods (API and database)
|
|
||||||
- Troubleshooting tips
|
|
||||||
|
|
||||||
**Location:** `MIGRATION_SUMMARY.md`
|
|
||||||
- Complete migration overview
|
|
||||||
- Data summary and limitations
|
|
||||||
- Verification steps
|
|
||||||
- Next steps
|
|
||||||
|
|
||||||
**Location:** `ANALYTICS_MIGRATION_COMPLETE.md` (this file)
|
|
||||||
- Quick reference guide
|
|
||||||
- Deployment instructions
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Option 1: Automated Deployment (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the deployment script
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This script will:
|
|
||||||
1. Copy files to your server
|
|
||||||
2. Provide import instructions
|
|
||||||
3. Show you the exact commands to run
|
|
||||||
|
|
||||||
### Option 2: Manual Deployment
|
|
||||||
|
|
||||||
#### Step 1: Copy files to server
|
|
||||||
```bash
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 2: SSH into server
|
|
||||||
```bash
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 3: Import data
|
|
||||||
|
|
||||||
**Method A: API Import (if API key is available)**
|
|
||||||
```bash
|
|
||||||
# Get your API key from Umami dashboard
|
|
||||||
# Add to .env: UMAMI_API_KEY=your-api-key
|
|
||||||
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
http://localhost:3000/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method B: Database Import (direct)**
|
|
||||||
```bash
|
|
||||||
# Import SQL file into PostgreSQL
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method C: Manual via Umami Dashboard**
|
|
||||||
1. Access Umami dashboard: https://analytics.infra.mintel.me
|
|
||||||
2. Go to Settings → Import
|
|
||||||
3. Upload `data/umami-import.json`
|
|
||||||
4. Select website ID: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
5. Click Import
|
|
||||||
|
|
||||||
## Your Umami Configuration
|
|
||||||
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
**Environment Variables** (from docker-compose.yml):
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server Details:**
|
|
||||||
- **Host:** alpha.mintel.me
|
|
||||||
- **User:** deploy
|
|
||||||
- **Path:** /home/deploy/sites/klz-cables.com
|
|
||||||
- **Umami API:** http://localhost:3000/api/import
|
|
||||||
|
|
||||||
## Data Summary
|
|
||||||
|
|
||||||
### What Was Migrated
|
|
||||||
- **Source:** Independent Analytics CSV (220 unique pages)
|
|
||||||
- **Migrated:** 7,634 simulated page view events
|
|
||||||
- **Metrics:** Page views, visitor counts, average duration
|
|
||||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
### What Was NOT Migrated
|
|
||||||
- Individual user sessions
|
|
||||||
- Real-time data
|
|
||||||
- Geographic data
|
|
||||||
- Referrer data
|
|
||||||
- Device/browser data
|
|
||||||
- Custom events
|
|
||||||
|
|
||||||
**Note:** The CSV contains aggregated data, not raw event data. The migration creates simulated historical data for reference only.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### After Import
|
|
||||||
1. **Check Umami dashboard:** https://analytics.infra.mintel.me
|
|
||||||
2. **Verify page view counts** match your expectations
|
|
||||||
3. **Check top pages** appear correctly
|
|
||||||
4. **Monitor for a few days** to ensure new data is being collected
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ 7,634 events imported
|
|
||||||
- ✅ 220 unique pages
|
|
||||||
- ✅ Historical view counts preserved
|
|
||||||
- ✅ Duration data maintained
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "SSH connection failed"
|
|
||||||
**Solution:** Check your SSH key and ensure `deploy@alpha.mintel.me` has access
|
|
||||||
|
|
||||||
### Issue: "API import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Check if Umami API is running: `docker compose ps`
|
|
||||||
2. Verify API key in `.env`: `UMAMI_API_KEY=your-key`
|
|
||||||
3. Try database import instead
|
|
||||||
|
|
||||||
### Issue: "Database import failed"
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure PostgreSQL is running: `docker compose ps`
|
|
||||||
2. Check database credentials
|
|
||||||
3. Run migrations first: `docker exec -it $(docker compose ps -q postgres) psql -U umami -d umami -c "SELECT 1;"`
|
|
||||||
|
|
||||||
### Issue: "No data appears in dashboard"
|
|
||||||
**Solution:**
|
|
||||||
1. Verify import completed successfully
|
|
||||||
2. Check Umami logs: `docker compose logs app`
|
|
||||||
3. Ensure website ID matches: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### 1. Import the Data
|
|
||||||
Choose one of the import methods above and run it.
|
|
||||||
|
|
||||||
### 2. Verify the Migration
|
|
||||||
- Check Umami dashboard
|
|
||||||
- Verify page view counts
|
|
||||||
- Confirm data appears correctly
|
|
||||||
|
|
||||||
### 3. Update Your Website
|
|
||||||
Your website is already configured with:
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Monitor for a Few Days
|
|
||||||
- Ensure Umami is collecting new data
|
|
||||||
- Compare with any remaining Independent Analytics data
|
|
||||||
- Verify tracking code is working
|
|
||||||
|
|
||||||
### 5. Clean Up
|
|
||||||
- Keep the original CSV as backup: `data/pages(1).csv`
|
|
||||||
- Store migration files for future reference
|
|
||||||
- Remove old Independent Analytics plugin from WordPress
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Migration Details
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source Plugin:** Independent Analytics v2.9.7
|
|
||||||
**Target Platform:** Umami Analytics
|
|
||||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
|
||||||
**Server:** alpha.mintel.me (deploy user)
|
|
||||||
**Status:** ✅ Ready for import
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Quick Command Reference:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy to server
|
|
||||||
./scripts/deploy-analytics-to-umami.sh
|
|
||||||
|
|
||||||
# Or manually:
|
|
||||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
|
||||||
ssh deploy@alpha.mintel.me
|
|
||||||
cd /home/deploy/sites/klz-cables.com
|
|
||||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Need help?** Check `scripts/README-migration.md` for detailed instructions.
|
|
||||||
@@ -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,19 +25,15 @@ 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_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
ARG NEXT_PUBLIC_TARGET
|
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_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
# Environment Variables Cleanup - Summary
|
|
||||||
|
|
||||||
## What Was Done
|
|
||||||
|
|
||||||
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Dockerfile ✅
|
|
||||||
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
|
|
||||||
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Only these build args now:
|
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. docker-compose.yml ✅
|
|
||||||
**Before**: 12+ individual environment variables listed
|
|
||||||
**After**: Single `env_file: .env` directive
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
app:
|
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
env_file:
|
|
||||||
- .env # All runtime vars loaded from here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. .gitea/workflows/deploy.yml ✅
|
|
||||||
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
|
|
||||||
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Before (FRAGILE):
|
|
||||||
ssh root@alpha.mintel.me \
|
|
||||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
|
||||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
|
||||||
... (12+ variables) \
|
|
||||||
/home/deploy/deploy.sh"
|
|
||||||
|
|
||||||
# After (AUTOMATED):
|
|
||||||
# 1. Create .env from secrets
|
|
||||||
cat > /tmp/klz-cables.env << EOF
|
|
||||||
NODE_ENV=production
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
# ... all other vars from secrets
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Upload to server
|
|
||||||
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
|
||||||
|
|
||||||
# 3. Deploy
|
|
||||||
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. New Files Created ✅
|
|
||||||
|
|
||||||
- **`.env.production`** - Template for reference (not used in automation)
|
|
||||||
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
|
|
||||||
- **`docs/SERVER_SETUP.md`** - Server setup instructions
|
|
||||||
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
|
|
||||||
|
|
||||||
### 5. Updated Files ✅
|
|
||||||
|
|
||||||
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Build Time (CI/CD)
|
|
||||||
```
|
|
||||||
Gitea Workflow
|
|
||||||
↓
|
|
||||||
Only passes NEXT_PUBLIC_* as --build-arg
|
|
||||||
↓
|
|
||||||
Docker Build
|
|
||||||
↓
|
|
||||||
Validates env vars
|
|
||||||
↓
|
|
||||||
Bakes NEXT_PUBLIC_* into client bundle
|
|
||||||
↓
|
|
||||||
Push to Registry
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime (Production Server) - FULLY AUTOMATED
|
|
||||||
```
|
|
||||||
Gitea Secrets
|
|
||||||
↓
|
|
||||||
Workflow creates .env file
|
|
||||||
↓
|
|
||||||
SCP uploads to server
|
|
||||||
↓
|
|
||||||
Secured (chmod 600, chown deploy:deploy)
|
|
||||||
↓
|
|
||||||
docker-compose.yml (env_file: .env)
|
|
||||||
↓
|
|
||||||
Loads .env into container
|
|
||||||
↓
|
|
||||||
Application runs with full config
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Benefits
|
|
||||||
|
|
||||||
### 1. Simplicity
|
|
||||||
- **Before**: 15+ Gitea secrets, variables in 3+ places
|
|
||||||
- **After**: All secrets in Gitea, automatically deployed
|
|
||||||
|
|
||||||
### 2. Clarity
|
|
||||||
- **Before**: Confusing duplication, unclear which vars go where
|
|
||||||
- **After**: Clear separation - build args vs runtime env file
|
|
||||||
|
|
||||||
### 3. Robustness
|
|
||||||
- **Before**: Fragile SSH command with 12+ inline variables
|
|
||||||
- **After**: Robust automated file generation and upload
|
|
||||||
|
|
||||||
### 4. Security
|
|
||||||
- **Before**: Secrets potentially exposed in CI logs
|
|
||||||
- **After**: Secrets masked in logs, .env auto-secured on server
|
|
||||||
|
|
||||||
### 5. Maintainability
|
|
||||||
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
|
|
||||||
- **After**: Update Gitea secrets only - deployment is automatic
|
|
||||||
|
|
||||||
### 6. **Zero Manual Steps** 🎉
|
|
||||||
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
|
|
||||||
- **After**: **Fully automated** - .env file created and uploaded on every deployment
|
|
||||||
|
|
||||||
## What You Need to Do
|
|
||||||
|
|
||||||
### Required Gitea Secrets
|
|
||||||
|
|
||||||
Ensure these secrets are configured in your Gitea repository:
|
|
||||||
|
|
||||||
**Build-Time (NEXT_PUBLIC_*):**
|
|
||||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
|
||||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
|
||||||
|
|
||||||
**Runtime:**
|
|
||||||
- `SENTRY_DSN` - Error tracking DSN
|
|
||||||
- `MAIL_HOST` - SMTP server
|
|
||||||
- `MAIL_PORT` - SMTP port (e.g., `587`)
|
|
||||||
- `MAIL_USERNAME` - SMTP username
|
|
||||||
- `MAIL_PASSWORD` - SMTP password
|
|
||||||
- `MAIL_FROM` - Sender email
|
|
||||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
|
||||||
|
|
||||||
**Infrastructure:**
|
|
||||||
- `REGISTRY_USER` - Docker registry username
|
|
||||||
- `REGISTRY_PASS` - Docker registry password
|
|
||||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
|
||||||
|
|
||||||
**Notifications:**
|
|
||||||
- `GOTIFY_URL` - Gotify notification server URL
|
|
||||||
- `GOTIFY_TOKEN` - Gotify application token
|
|
||||||
|
|
||||||
### That's It!
|
|
||||||
|
|
||||||
**No manual steps required.** Just push to main branch and the workflow will:
|
|
||||||
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
|
|
||||||
2. ✅ Create .env file from all secrets
|
|
||||||
3. ✅ Upload .env to server
|
|
||||||
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
|
|
||||||
5. ✅ Pull latest image
|
|
||||||
6. ✅ Deploy with docker-compose
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
```
|
|
||||||
Modified:
|
|
||||||
├── Dockerfile (removed redundant build args)
|
|
||||||
├── docker-compose.yml (use env_file instead of individual vars)
|
|
||||||
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
|
|
||||||
├── .env.example (clear documentation)
|
|
||||||
├── lib/services/create-services.ts (removed redundant dotenv usage)
|
|
||||||
└── scripts/migrate-*.ts (removed redundant dotenv usage)
|
|
||||||
|
|
||||||
Created:
|
|
||||||
├── .env.production (reference template)
|
|
||||||
├── docs/DEPLOYMENT.md (deployment guide)
|
|
||||||
├── docs/SERVER_SETUP.md (server setup guide)
|
|
||||||
├── docs/ENV_MIGRATION.md (migration guide)
|
|
||||||
└── ENV_CLEANUP_SUMMARY.md (this file)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Developer pushes to main branch │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Gitea Workflow Triggered │
|
|
||||||
│ │
|
|
||||||
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
|
|
||||||
│ 2. Push to registry │
|
|
||||||
│ 3. Generate .env from secrets │
|
|
||||||
│ 4. Upload .env to server via SCP │
|
|
||||||
│ 5. SSH to server and deploy │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Production Server │
|
|
||||||
│ │
|
|
||||||
│ 1. .env file secured (600, deploy:deploy) │
|
|
||||||
│ 2. Docker login to registry │
|
|
||||||
│ 3. Pull latest image │
|
|
||||||
│ 4. docker-compose down │
|
|
||||||
│ 5. docker-compose up -d (loads .env) │
|
|
||||||
│ 6. Health checks pass │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ✅ Deployment Complete - Gotify Notification Sent │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparison: Before vs After
|
|
||||||
|
|
||||||
| Aspect | Before | After |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
|
|
||||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
|
||||||
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
|
|
||||||
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
|
|
||||||
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
|
|
||||||
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
|
|
||||||
| **Clarity** | Confusing duplication | Clear separation |
|
|
||||||
| **Robustness** | Fragile SSH command | Robust automation |
|
|
||||||
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
|
|
||||||
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
|
|
||||||
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
|
|
||||||
- **[.env.example](.env.example)** - Environment variables reference
|
|
||||||
- **[.env.production](.env.production)** - Production template (for reference)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Deployment Fails
|
|
||||||
|
|
||||||
1. **Check Gitea secrets** - Ensure all required secrets are set
|
|
||||||
2. **Check workflow logs** - Look for specific error messages
|
|
||||||
3. **SSH to server** - Verify .env file exists and has correct permissions
|
|
||||||
4. **Check container logs** - `docker-compose logs -f app`
|
|
||||||
|
|
||||||
### .env File Issues
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
- Creates .env from secrets
|
|
||||||
- Uploads to server
|
|
||||||
- Sets 600 permissions
|
|
||||||
- Sets deploy:deploy ownership
|
|
||||||
|
|
||||||
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
|
|
||||||
|
|
||||||
### Missing Environment Variables
|
|
||||||
|
|
||||||
If a variable is missing:
|
|
||||||
1. Add it to Gitea secrets
|
|
||||||
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
|
|
||||||
3. Push to trigger new deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
|
|
||||||
|
|
||||||
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Analytics Migration Summary: Independent Analytics → Umami
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Successfully migrated analytics data from Independent Analytics WordPress plugin to Umami format.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. Migration Script
|
|
||||||
- **Location:** `scripts/migrate-analytics-to-umami.py`
|
|
||||||
- **Purpose:** Converts Independent Analytics CSV data to Umami format
|
|
||||||
- **Features:**
|
|
||||||
- JSON format (for API import)
|
|
||||||
- SQL format (for direct database import)
|
|
||||||
- API payload format (for manual import)
|
|
||||||
|
|
||||||
### 2. Migration Documentation
|
|
||||||
- **Location:** `scripts/README-migration.md`
|
|
||||||
- **Purpose:** Step-by-step guide for migration
|
|
||||||
- **Contents:**
|
|
||||||
- Prerequisites
|
|
||||||
- Migration options
|
|
||||||
- Import instructions
|
|
||||||
- Troubleshooting guide
|
|
||||||
|
|
||||||
### 3. Output Files
|
|
||||||
|
|
||||||
#### JSON Import File
|
|
||||||
- **Location:** `data/umami-import.json`
|
|
||||||
- **Size:** 2.1 MB
|
|
||||||
- **Records:** 7,634 simulated page view events
|
|
||||||
- **Format:** JSON array of Umami-compatible events
|
|
||||||
- **Use Case:** Import via Umami API
|
|
||||||
|
|
||||||
#### SQL Import File
|
|
||||||
- **Location:** `data/umami-import.sql`
|
|
||||||
- **Size:** 1.8 MB
|
|
||||||
- **Records:** 5,250 SQL INSERT statements
|
|
||||||
- **Format:** PostgreSQL-compatible SQL
|
|
||||||
- **Use Case:** Direct database import
|
|
||||||
|
|
||||||
## Data Migrated
|
|
||||||
|
|
||||||
### Source Data
|
|
||||||
- **File:** `data/pages(1).csv`
|
|
||||||
- **Records:** 220 unique pages
|
|
||||||
- **Metrics:**
|
|
||||||
- Page titles
|
|
||||||
- Visitor counts
|
|
||||||
- View counts
|
|
||||||
- Average view duration
|
|
||||||
- Bounce rates
|
|
||||||
- URLs
|
|
||||||
- Page types (Page, Post, Product, Category, etc.)
|
|
||||||
|
|
||||||
### Migrated Data
|
|
||||||
- **Total Events:** 7,634 simulated page views
|
|
||||||
- **Unique Pages:** 220
|
|
||||||
- **Data Points:**
|
|
||||||
- Website ID: `klz-cables`
|
|
||||||
- Path: Page URLs
|
|
||||||
- Duration: Preserved from average view duration
|
|
||||||
- Timestamp: Current time (for historical reference)
|
|
||||||
|
|
||||||
## Migration Process
|
|
||||||
|
|
||||||
### Step 1: Run Migration Script
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.json \
|
|
||||||
--format json \
|
|
||||||
--site-id klz-cables
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Choose Import Method
|
|
||||||
|
|
||||||
#### Option A: API Import (Recommended)
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
https://your-umami-instance.com/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Database Import
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami -f data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify Migration
|
|
||||||
1. Check Umami dashboard
|
|
||||||
2. Verify page view counts
|
|
||||||
3. Confirm data appears correctly
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### Data Limitations
|
|
||||||
The CSV export contains **aggregated data**, not raw event data:
|
|
||||||
- ✅ Page views (total counts)
|
|
||||||
- ✅ Visitor counts
|
|
||||||
- ✅ Average view duration
|
|
||||||
- ❌ Individual user sessions
|
|
||||||
- ❌ Real-time data
|
|
||||||
- ❌ Geographic data
|
|
||||||
- ❌ Referrer data
|
|
||||||
- ❌ Device/browser data
|
|
||||||
|
|
||||||
### What Gets Imported
|
|
||||||
The migration creates **simulated historical data**:
|
|
||||||
- Each page view becomes a separate event
|
|
||||||
- Timestamps are set to current time
|
|
||||||
- Duration is preserved from average view duration
|
|
||||||
- No session tracking (each view is independent)
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
1. **Start fresh with Umami** - Let Umami collect new data going forward
|
|
||||||
2. **Keep the original CSV** - Store as backup for future reference
|
|
||||||
3. **Update your website** - Replace Independent Analytics tracking with Umami tracking
|
|
||||||
4. **Monitor for a few days** - Verify Umami is collecting data correctly
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Check Generated Files
|
|
||||||
```bash
|
|
||||||
# Verify JSON file
|
|
||||||
ls -lh data/umami-import.json
|
|
||||||
head -20 data/umami-import.json
|
|
||||||
|
|
||||||
# Verify SQL file
|
|
||||||
ls -lh data/umami-import.sql
|
|
||||||
head -20 data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
- ✅ JSON file: ~2.1 MB, 7,634 records
|
|
||||||
- ✅ SQL file: ~1.8 MB, 5,250 statements
|
|
||||||
- ✅ Both files contain valid data for Umami import
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Set up Umami instance** (if not already done)
|
|
||||||
2. **Create a website** in Umami dashboard
|
|
||||||
3. **Get your Website ID** and API key
|
|
||||||
4. **Run the migration script** with your credentials
|
|
||||||
5. **Import the data** using your preferred method
|
|
||||||
6. **Verify the migration** in Umami dashboard
|
|
||||||
7. **Update your website** to use Umami tracking code
|
|
||||||
8. **Monitor for a few days** to ensure data collection works
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "ModuleNotFoundError"
|
|
||||||
**Solution:** Ensure Python 3 is installed: `python3 --version`
|
|
||||||
|
|
||||||
### Issue: "Permission denied"
|
|
||||||
**Solution:** Make script executable: `chmod +x scripts/migrate-analytics-to-umami.py`
|
|
||||||
|
|
||||||
### Issue: API import fails
|
|
||||||
**Solution:** Check API key, website ID, and Umami instance accessibility
|
|
||||||
|
|
||||||
### Issue: SQL import fails
|
|
||||||
**Solution:** Verify database credentials and run migrations first
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ **Completed:**
|
|
||||||
- Created migration script with 3 output formats
|
|
||||||
- Generated JSON import file (2.1 MB, 7,634 events)
|
|
||||||
- Generated SQL import file (1.8 MB, 5,250 statements)
|
|
||||||
- Created comprehensive documentation
|
|
||||||
|
|
||||||
📊 **Data Migrated:**
|
|
||||||
- 220 unique pages
|
|
||||||
- 7,634 simulated page view events
|
|
||||||
- Historical view counts and durations
|
|
||||||
|
|
||||||
🎯 **Ready for Import:**
|
|
||||||
- Choose API or SQL import method
|
|
||||||
- Follow instructions in `scripts/README-migration.md`
|
|
||||||
- Verify data in Umami dashboard
|
|
||||||
|
|
||||||
**Migration Date:** 2026-01-25
|
|
||||||
**Source:** Independent Analytics v2.9.7
|
|
||||||
**Target:** Umami Analytics
|
|
||||||
**Site ID:** klz-cables
|
|
||||||
51
README.md
51
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
|
||||||
|
|
||||||
@@ -409,6 +447,7 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
|||||||
## 📞 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
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
@@ -59,7 +60,8 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
export default async function StandardPage({ params }: PageProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const pageData = await getPageBySlug(slug, locale);
|
const pageData = await getPageBySlug(slug, locale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { locale: string } }
|
{ params }: { params: Promise<{ locale: string }> },
|
||||||
) {
|
) {
|
||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams, origin } = new URL(request.url);
|
||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
const locale = params.locale || 'en';
|
const { locale } = await params;
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return new Response('Missing slug', { status: 400 });
|
return new Response('Missing slug', { status: 400 });
|
||||||
@@ -23,24 +23,29 @@ export async function GET(
|
|||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
|
|
||||||
// Check if it's a category page
|
// Check if it's a category page
|
||||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
const categories = [
|
||||||
|
'low-voltage-cables',
|
||||||
|
'medium-voltage-cables',
|
||||||
|
'high-voltage-cables',
|
||||||
|
'solar-cables',
|
||||||
|
];
|
||||||
if (categories.includes(slug)) {
|
if (categories.includes(slug)) {
|
||||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const categoryKey = slug
|
||||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
.replace(/-cables$/, '')
|
||||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||||
|
? t(`categories.${categoryKey}.title`)
|
||||||
|
: slug;
|
||||||
|
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||||
|
? t(`categories.${categoryKey}.description`)
|
||||||
|
: '';
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||||
<OGImageTemplate
|
|
||||||
title={categoryTitle}
|
|
||||||
description={categoryDesc}
|
|
||||||
label="Product Category"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,24 +56,21 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const featuredImage = product.frontmatter.images?.[0]
|
const featuredImage = product.frontmatter.images?.[0]
|
||||||
? (product.frontmatter.images[0].startsWith('http')
|
? product.frontmatter.images[0].startsWith('http')
|
||||||
? product.frontmatter.images[0]
|
? product.frontmatter.images[0]
|
||||||
: `${origin}${product.frontmatter.images[0]}`)
|
: `${origin}${product.frontmatter.images[0]}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
<OGImageTemplate
|
||||||
<OGImageTemplate
|
title={product.frontmatter.title}
|
||||||
title={product.frontmatter.title}
|
description={product.frontmatter.description}
|
||||||
description={product.frontmatter.description}
|
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
image={featuredImage}
|
||||||
image={featuredImage}
|
/>,
|
||||||
/>
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ import { Heading } from '@/components/ui';
|
|||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||||
params: { locale, slug },
|
const { locale, slug } = await params;
|
||||||
}: BlogPostProps): Promise<Metadata> {
|
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
@@ -56,7 +55,8 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
export default async function BlogPost({ params }: BlogPostProps) {
|
||||||
|
const { locale, slug } = await params;
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
title: t('title'),
|
title: t('title'),
|
||||||
@@ -39,7 +40,8 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,16 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import ContactMap from '@/components/ContactMap';
|
||||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
|
||||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||||
params: { locale },
|
const { locale } = await params;
|
||||||
}: ContactPageProps): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
@@ -66,7 +57,7 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params }: ContactPageProps) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
const { locale } = params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
@@ -249,7 +240,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LeafletMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import Header from '@/components/Header';
|
|||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
|
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
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),
|
||||||
@@ -31,27 +33,57 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Providing all messages to the client
|
const { locale } = await params;
|
||||||
// side is the easiest way to get started
|
|
||||||
const messages = await getMessages();
|
// Ensure locale is a valid string, fallback to 'en'
|
||||||
|
const supportedLocales = ['en', 'de'];
|
||||||
|
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||||
|
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||||
|
|
||||||
|
let messages = {};
|
||||||
|
try {
|
||||||
|
messages = await getMessages();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||||
|
messages = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pageview on the server with high-fidelity header context
|
||||||
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in serverServices.analytics) {
|
||||||
|
(serverServices.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track initial server-side pageview
|
||||||
|
serverServices.analytics.trackPageview();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
<html lang={safeLocale} className="scroll-smooth overflow-x-hidden">
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
{/* Sends pageviews for client-side navigations */}
|
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider />
|
||||||
|
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { getTranslations } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
|
|
||||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function HomePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -55,10 +60,11 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
// Use translations for meta where available (namespace: Index.meta)
|
// Use translations for meta where available (namespace: Index.meta)
|
||||||
// Fallback to a sensible default if translation keys are missing.
|
// Fallback to a sensible default if translation keys are missing.
|
||||||
let t;
|
let t;
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string[];
|
slug: string[];
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ const components = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = params;
|
const { locale, slug } = await params;
|
||||||
const productSlug = slug[slug.length - 1];
|
const productSlug = slug[slug.length - 1];
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
params: { locale },
|
const { locale } = await params;
|
||||||
}: ProductsPageProps): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
@@ -47,13 +46,14 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Get translated category slugs
|
// Get translated category slugs
|
||||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -61,28 +61,28 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${lowVoltageSlug}`,
|
href: `/${locale}/products/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${mediumVoltageSlug}`,
|
href: `/${locale}/products/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${params.locale}/products/${highVoltageSlug}`,
|
href: `/${locale}/products/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${params.locale}/products/${solarSlug}`,
|
href: `/${locale}/products/${solarSlug}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${params.locale}/contact`}
|
href={`/${locale}/contact`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
|
|||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
|
||||||
interface TeamPageProps {
|
interface TeamPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
const title = t('meta.title') || t('hero.subtitle');
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
const description = t('meta.description') || t('hero.title');
|
const description = t('meta.description') || t('hero.title');
|
||||||
@@ -43,7 +44,8 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,13 +3,30 @@
|
|||||||
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' });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in services.analytics) {
|
||||||
|
(services.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
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;
|
||||||
@@ -51,38 +68,86 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
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 ? `Product Inquiry: ${productName}` : 'New Contact Form Submission';
|
const notificationSubject = productName
|
||||||
|
? `Product Inquiry: ${productName}`
|
||||||
|
: '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,
|
||||||
|
subject: notificationSubject,
|
||||||
|
html: notificationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
await services.notifications.notify({
|
||||||
title: `📩 ${subject}`,
|
title: `📩 ${notificationSubject}`,
|
||||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
priority: 5,
|
priority: 5,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
logger.error('Failed to send contact form email', { error: result.error });
|
// Track success
|
||||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
services.analytics.track('contact-form-success', {
|
||||||
|
is_product_request: !!productName,
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
await services.notifications.notify({
|
||||||
title: '🚨 Contact Form Error',
|
title: '🚨 Contact Form Error',
|
||||||
message: `Failed to send email for ${name} (${email}). Error: ${JSON.stringify(result.error)}`,
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
priority: 8,
|
priority: 8,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handleFeedbackRequest(req as any, {
|
||||||
|
url: config.infraCMS.url,
|
||||||
|
token: config.infraCMS.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||||
|
}
|
||||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||||
|
*
|
||||||
|
* This Route Handler receives Sentry envelopes from the client,
|
||||||
|
* injects the correct DSN if needed, and forwards them to the
|
||||||
|
* internal GlitchTip/Sentry instance.
|
||||||
|
*
|
||||||
|
* This hides the real DSN from the client and bypasses ad-blockers
|
||||||
|
* that target Sentry's default ingest endpoints.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envelope = await request.text();
|
||||||
|
|
||||||
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
|
const lines = envelope.split('\n');
|
||||||
|
if (lines.length < 1) {
|
||||||
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
|
if (!realDsn) {
|
||||||
|
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsnUrl = new URL(realDsn);
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
logger.debug('Relaying Sentry envelope', {
|
||||||
|
projectId,
|
||||||
|
host: dsnUrl.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(relayUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: envelope,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy for Umami Analytics.
|
||||||
|
*
|
||||||
|
* This Route Handler receives tracking events from the browser,
|
||||||
|
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||||
|
* internal Umami API endpoint.
|
||||||
|
*
|
||||||
|
* This ensures:
|
||||||
|
* 1. The Website ID is NOT leaked to the client bundle.
|
||||||
|
* 2. The Umami API endpoint is hidden behind our domain.
|
||||||
|
* 3. We have full control over the tracking data.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
// Inject the secret websiteId from server config
|
||||||
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
|
if (!websiteId) {
|
||||||
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the enhanced payload with the secret ID
|
||||||
|
const enhancedPayload = {
|
||||||
|
...payload,
|
||||||
|
website: websiteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||||
|
|
||||||
|
// Log the event (internal only)
|
||||||
|
logger.debug('Forwarding analytics event', {
|
||||||
|
type,
|
||||||
|
url: payload.url,
|
||||||
|
website: websiteId.slice(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||||
|
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
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>
|
||||||
@@ -133,13 +150,31 @@ export default function ContactForm() {
|
|||||||
>
|
>
|
||||||
{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>
|
||||||
|
|||||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||||
|
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ContactMapProps {
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||||
|
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||||
|
}
|
||||||
@@ -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,17 +157,43 @@ 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,25 +3,15 @@
|
|||||||
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
|
||||||
*
|
*
|
||||||
* 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 handles navigation events for the Umami analytics service.
|
||||||
*
|
*
|
||||||
* @example
|
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||||
* ```tsx
|
* so it's no longer needed as a prop here.
|
||||||
* // In your layout.tsx
|
|
||||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
|
||||||
* <UmamiScript />
|
|
||||||
* <Header />
|
|
||||||
* <main>{children}</main>
|
|
||||||
* <Footer />
|
|
||||||
* <AnalyticsProvider />
|
|
||||||
* </NextIntlClientProvider>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider() {
|
export default function AnalyticsProvider() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -34,26 +24,12 @@ export default function AnalyticsProvider() {
|
|||||||
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
|
||||||
|
// The service will relay this to our internal proxy which injects the Website ID
|
||||||
services.analytics.trackPageview(url);
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// Services like logger are already sub-initialized in getAppServices()
|
||||||
console.log('[Umami] Tracked pageview:', url);
|
// so we don't need to log here manually.
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
return null;
|
||||||
if (!websiteId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
0
directus/migrations/.keep
Normal file
0
directus/migrations/.keep
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
- collection: product_requests
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: product_requests
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: null
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: inventory
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: product_requests
|
||||||
|
- collection: visual_feedback
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: feedback
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: comment
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback_comments
|
||||||
|
fields:
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: null
|
||||||
|
display_options: null
|
||||||
|
field: id
|
||||||
|
group: null
|
||||||
|
hidden: true
|
||||||
|
interface: null
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 1
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: uuid
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: false
|
||||||
|
is_unique: true
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: true
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: status
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#e1f5fe'
|
||||||
|
color: '#01579b'
|
||||||
|
text: Open
|
||||||
|
value: open
|
||||||
|
- background: '#e8f5e9'
|
||||||
|
color: '#1b5e20'
|
||||||
|
text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- background: '#fafafa'
|
||||||
|
color: '#212121'
|
||||||
|
text: Closed
|
||||||
|
value: closed
|
||||||
|
show_as_dot: true
|
||||||
|
field: status
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Open
|
||||||
|
value: open
|
||||||
|
- text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- text: Closed
|
||||||
|
value: closed
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 2
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: status
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: open
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#fff9c4'
|
||||||
|
color: '#fbc02d'
|
||||||
|
text: Design
|
||||||
|
value: design
|
||||||
|
- background: '#f3e5f5'
|
||||||
|
color: '#7b1fa2'
|
||||||
|
text: Content
|
||||||
|
value: content
|
||||||
|
show_as_dot: true
|
||||||
|
field: type
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Design
|
||||||
|
value: design
|
||||||
|
- text: Content
|
||||||
|
value: content
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 3
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: type
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: formatted-text
|
||||||
|
display_options:
|
||||||
|
soft_limit: 100
|
||||||
|
field: text
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input-multiline
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 4
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: text
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: url
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: link
|
||||||
|
display_options:
|
||||||
|
url: '{{url}}'
|
||||||
|
target: _blank
|
||||||
|
icon: open_in_new
|
||||||
|
field: url
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 5
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: url
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: person
|
||||||
|
header_text: User Information
|
||||||
|
sort: 6
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 1
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_identity
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: psychology
|
||||||
|
header_text: Technical Context
|
||||||
|
sort: 7
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 1
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: selector
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: x
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: 'y'
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: datetime
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 8
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: null
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-relational
|
||||||
|
sort: 2
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: character varying
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: text
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
systemFields:
|
||||||
|
- collection: directus_activity
|
||||||
|
field: timestamp
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: activity
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: parent
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
relations:
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
related_collection: visual_feedback
|
||||||
|
schema:
|
||||||
|
column: feedback_id
|
||||||
|
foreign_key_column: id
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
table: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
junction_field: null
|
||||||
|
many_collection: visual_feedback_comments
|
||||||
|
many_field: feedback_id
|
||||||
|
one_allowed_m2m: false
|
||||||
|
one_collection: visual_feedback
|
||||||
|
one_deselect_action: nullify
|
||||||
|
one_field: null
|
||||||
|
sort_field: null
|
||||||
590
directus/schema/snapshot.yaml
Normal file
590
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
- collection: product_requests
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: product_requests
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: null
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: inventory
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: product_requests
|
||||||
|
- collection: visual_feedback
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: feedback
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
archive_field: null
|
||||||
|
archive_value: null
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}}: {{text}}'
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
icon: comment
|
||||||
|
item_duplication_fields: null
|
||||||
|
note: null
|
||||||
|
preview_url: null
|
||||||
|
singleton: false
|
||||||
|
sort: null
|
||||||
|
sort_field: null
|
||||||
|
translations: null
|
||||||
|
unarchive_value: null
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback_comments
|
||||||
|
fields:
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: null
|
||||||
|
display_options: null
|
||||||
|
field: id
|
||||||
|
group: null
|
||||||
|
hidden: true
|
||||||
|
interface: null
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 1
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: uuid
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: false
|
||||||
|
is_unique: true
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: true
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: status
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#e1f5fe'
|
||||||
|
color: '#01579b'
|
||||||
|
text: Open
|
||||||
|
value: open
|
||||||
|
- background: '#e8f5e9'
|
||||||
|
color: '#1b5e20'
|
||||||
|
text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- background: '#fafafa'
|
||||||
|
color: '#212121'
|
||||||
|
text: Closed
|
||||||
|
value: closed
|
||||||
|
show_as_dot: true
|
||||||
|
field: status
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Open
|
||||||
|
value: open
|
||||||
|
- text: Resolved
|
||||||
|
value: resolved
|
||||||
|
- text: Closed
|
||||||
|
value: closed
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 2
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: status
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: open
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: labels
|
||||||
|
display_options:
|
||||||
|
choices:
|
||||||
|
- background: '#fff9c4'
|
||||||
|
color: '#fbc02d'
|
||||||
|
text: Design
|
||||||
|
value: design
|
||||||
|
- background: '#f3e5f5'
|
||||||
|
color: '#7b1fa2'
|
||||||
|
text: Content
|
||||||
|
value: content
|
||||||
|
show_as_dot: true
|
||||||
|
field: type
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: select-dropdown
|
||||||
|
note: null
|
||||||
|
options:
|
||||||
|
choices:
|
||||||
|
- text: Design
|
||||||
|
value: design
|
||||||
|
- text: Content
|
||||||
|
value: content
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 3
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: type
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: formatted-text
|
||||||
|
display_options:
|
||||||
|
soft_limit: 100
|
||||||
|
field: text
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input-multiline
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: false
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 4
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: text
|
||||||
|
default_value: null
|
||||||
|
max_length: null
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: url
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: link
|
||||||
|
display_options:
|
||||||
|
url: '{{url}}'
|
||||||
|
target: _blank
|
||||||
|
icon: open_in_new
|
||||||
|
field: url
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 5
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: url
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: null
|
||||||
|
max_length: 255
|
||||||
|
numeric_precision: null
|
||||||
|
numeric_scale: null
|
||||||
|
is_nullable: true
|
||||||
|
is_unique: false
|
||||||
|
is_indexed: false
|
||||||
|
is_primary_key: false
|
||||||
|
is_generated: false
|
||||||
|
generation_expression: null
|
||||||
|
has_auto_increment: false
|
||||||
|
foreign_key_table: null
|
||||||
|
foreign_key_column: null
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: person
|
||||||
|
header_text: User Information
|
||||||
|
sort: 6
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 1
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
group: user_info_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_identity
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: group-detail
|
||||||
|
options:
|
||||||
|
header_icon: psychology
|
||||||
|
header_text: Technical Context
|
||||||
|
sort: 7
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
width: full
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 1
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: selector
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: x
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
group: technical_details_group
|
||||||
|
hidden: false
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: 'y'
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
conditions: null
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
group: null
|
||||||
|
hidden: false
|
||||||
|
interface: datetime
|
||||||
|
note: null
|
||||||
|
options: null
|
||||||
|
readonly: true
|
||||||
|
required: false
|
||||||
|
searchable: true
|
||||||
|
sort: 8
|
||||||
|
special: null
|
||||||
|
translations: null
|
||||||
|
validation: null
|
||||||
|
validation_message: null
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: null
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-relational
|
||||||
|
sort: 2
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
width: half
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: character varying
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: text
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
display: datetime
|
||||||
|
display_options:
|
||||||
|
relative: true
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
systemFields:
|
||||||
|
- collection: directus_activity
|
||||||
|
field: timestamp
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: activity
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: parent
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
relations:
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
related_collection: visual_feedback
|
||||||
|
schema:
|
||||||
|
column: feedback_id
|
||||||
|
foreign_key_column: id
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
table: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
junction_field: null
|
||||||
|
many_collection: visual_feedback_comments
|
||||||
|
many_field: feedback_id
|
||||||
|
one_allowed_m2m: false
|
||||||
|
one_collection: visual_feedback
|
||||||
|
one_deselect_action: nullify
|
||||||
|
one_field: null
|
||||||
|
sort_field: null
|
||||||
@@ -1,36 +1,83 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
klz-app:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm install && npx next dev"
|
command: sh -c "npm install --legacy-peer-deps && npx next dev"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
# Docker Internal Communication
|
# Docker Internal Communication
|
||||||
DIRECTUS_URL: http://directus:8055
|
DIRECTUS_URL: http://directus:8055
|
||||||
|
INTERNAL_DIRECTUS_URL: http://directus:8055
|
||||||
|
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
|
||||||
|
GATEKEEPER_URL: http://gatekeeper:3000
|
||||||
|
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
|
||||||
|
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Clear all production-related TLS/Middleware settings for the main routers
|
# Global local settings
|
||||||
- "traefik.http.routers.klz-cables.entrypoints=web"
|
- "traefik.http.routers.klz-cables-local.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
|
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
|
||||||
- "traefik.http.routers.klz-cables.tls=false"
|
- "traefik.http.routers.klz-cables-local.tls=false"
|
||||||
- "traefik.http.routers.klz-cables.middlewares="
|
- "traefik.http.routers.klz-cables-local.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
|
||||||
|
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
# Web direct router
|
||||||
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
|
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables-web.middlewares="
|
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.klz-cables-directus.entrypoints=web"
|
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
|
||||||
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
|
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
|
||||||
- "traefik.http.routers.klz-cables-directus.tls=false"
|
- "traefik.http.routers.klz-cables-directus-local.tls=false"
|
||||||
- "traefik.http.routers.klz-cables-directus.middlewares="
|
- "traefik.http.routers.klz-cables-directus-local.middlewares="
|
||||||
|
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
|
||||||
|
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
ports:
|
ports:
|
||||||
- "8055:8055"
|
- "${DIRECTUS_PORT:-8055}:8055"
|
||||||
environment:
|
environment:
|
||||||
PUBLIC_URL: http://cms.klz.localhost
|
PUBLIC_URL: http://cms.klz.localhost
|
||||||
|
|
||||||
|
gatekeeper:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app/packages/gatekeeper
|
||||||
|
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
|
||||||
|
volumes:
|
||||||
|
- /Users/marcmintel/Projects/at-mintel:/app
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
environment:
|
||||||
|
DIRECTUS_URL: http://directus:8055
|
||||||
|
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
|
COOKIE_DOMAIN: localhost
|
||||||
|
NODE_ENV: development
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
|
||||||
|
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
|
||||||
|
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|||||||
@@ -1,61 +1,102 @@
|
|||||||
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=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||||
- "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 (Protected)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(${TRAEFIK_HOST})"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||||
- "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.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
|
||||||
|
|
||||||
|
# HTTPS router (Unprotected - for Analytics & Errors)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
|
||||||
|
|
||||||
|
- "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
|
- "traefik.docker.network=infra"
|
||||||
- "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"
|
# Middleware Definitions
|
||||||
# Middlewares
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
|
|
||||||
|
|
||||||
# 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:-klz-cables.com}`)"
|
||||||
- "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"
|
||||||
|
|
||||||
|
# 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-Ssl=on"
|
||||||
|
|
||||||
# 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:1.4.0
|
||||||
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:-klz-cables.com}
|
||||||
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||||
|
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
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}
|
||||||
@@ -79,20 +120,23 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./directus/uploads:/directus/uploads
|
- ./directus/uploads:/directus/uploads
|
||||||
- ./directus/extensions:/directus/extensions
|
- ./directus/extensions:/directus/extensions
|
||||||
|
- ./directus/schema:/directus/schema
|
||||||
|
- ./directus/migrations:/directus/migrations
|
||||||
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:
|
||||||
@@ -103,6 +147,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- infra
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
# HTTP ⇒ HTTPS redirect
|
|
||||||
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
|
||||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
|
||||||
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
|
|
||||||
# HTTPS router
|
|
||||||
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
|
|
||||||
- "traefik.http.routers.klz-cables.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.klz-cables.tls.certresolver=le"
|
|
||||||
- "traefik.http.routers.klz-cables.tls=true"
|
|
||||||
- "traefik.http.routers.klz-cables.service=klz-cables"
|
|
||||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
|
|
||||||
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
|
|
||||||
# Forwarded Headers
|
|
||||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
|
||||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
|
||||||
# Middlewares
|
|
||||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
|
||||||
|
|
||||||
cms:
|
|
||||||
build:
|
|
||||||
context: ./cms
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- infra
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
DATABASE_CLIENT: postgres
|
|
||||||
DATABASE_HOST: cms-db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_NAME: ${STRAPI_DATABASE_NAME:-strapi}
|
|
||||||
DATABASE_USERNAME: ${STRAPI_DATABASE_USERNAME:-strapi}
|
|
||||||
DATABASE_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
|
|
||||||
NODE_ENV: ${NODE_ENV:-development}
|
|
||||||
STRAPI_URL: ${STRAPI_URL:-https://cms.klz-cables.com}
|
|
||||||
volumes:
|
|
||||||
- ./cms/config:/opt/app/config
|
|
||||||
- ./cms/src:/opt/app/src
|
|
||||||
- ./cms/package.json:/opt/app/package.json
|
|
||||||
- ./cms/package-lock.json:/opt/app/package-lock.json
|
|
||||||
- ./cms/public/uploads:/opt/app/public/uploads
|
|
||||||
- ./cms/dist:/opt/app/dist
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.klz-cms.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
|
|
||||||
- "traefik.http.routers.klz-cms.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.klz-cms.tls.certresolver=le"
|
|
||||||
- "traefik.http.routers.klz-cms.tls=true"
|
|
||||||
- "traefik.http.services.klz-cms.loadbalancer.server.port=1337"
|
|
||||||
|
|
||||||
cms-db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- infra
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${STRAPI_DATABASE_NAME:-strapi}
|
|
||||||
POSTGRES_USER: ${STRAPI_DATABASE_USERNAME:-strapi}
|
|
||||||
POSTGRES_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
|
|
||||||
volumes:
|
|
||||||
- cms-db-data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
networks:
|
|
||||||
infra:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cms-db-data:
|
|
||||||
@@ -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 \
|
||||||
@@ -251,6 +260,7 @@ 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
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ https://logs.infra.mintel.me
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## SMTP
|
||||||
|
|
||||||
|
# SMTP Config
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM= # muss im projekt gesetzt werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Image Optimization (imgproxy)
|
||||||
|
|
||||||
|
Alle Bilder werden zentral über **imgproxy** optimiert, resized und in moderne Formate (WebP, AVIF) konvertiert.
|
||||||
|
|
||||||
|
**Basis-URL**
|
||||||
|
https://img.infra.mintel.me
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://img.infra.mintel.me/unsafe/rs:800x600/plain/https://example.com/bild.jpg
|
||||||
|
https://img.infra.mintel.me/rs:400x/plain/https://picsum.photos/2000/1333
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Production Platform (Alpha)
|
## Production Platform (Alpha)
|
||||||
|
|
||||||
Alpha runs all customer websites and is publicly reachable.
|
Alpha runs all customer websites and is publicly reachable.
|
||||||
|
|||||||
45
eslint.config.mjs
Normal file
45
eslint.config.mjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import baseConfig from "@mintel/eslint-config";
|
||||||
|
import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"**/.next/**",
|
||||||
|
".next/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"dist/**",
|
||||||
|
"**/out/**",
|
||||||
|
"out/**",
|
||||||
|
"**/.pnpm-store/**",
|
||||||
|
"**/at-mintel/**",
|
||||||
|
"at-mintel/**",
|
||||||
|
"**/.git/**",
|
||||||
|
"*.js",
|
||||||
|
"*.mjs",
|
||||||
|
"scripts/**",
|
||||||
|
"tests/**",
|
||||||
|
"next-env.d.ts"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
...nextConfig.map((config) => ({
|
||||||
|
...config,
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
...config.rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"@next/next/no-img-element": "warn"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
];
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS-FL-2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Schichtenmantel": "ja",
|
|
||||||
"Kabel querwasserdicht": "ja",
|
|
||||||
"Kabel längswasserdicht": "ja",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Mantelwanddicke": "2.1 mm",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform": "rund",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Mantelwanddicke": "2.1 mm",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSF2Y/",
|
|
||||||
"verwendung": "",
|
|
||||||
"technischeDaten": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSY/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Cu, blank",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "PVC DMV6",
|
|
||||||
"Mantelfarbe": "rot",
|
|
||||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform": "rund",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSF2Y/",
|
|
||||||
"verwendung": "",
|
|
||||||
"technischeDaten": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS-FL-2Y/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Mantelmaterial": "Polyethylen DMP2",
|
|
||||||
"Schichtenmantel": "ja",
|
|
||||||
"Kabel querwasserdicht": "ja",
|
|
||||||
"Kabel längswasserdicht": "ja",
|
|
||||||
"Mantelfarbe": "schwarz",
|
|
||||||
"Flammwidrigkeit": "keine",
|
|
||||||
"UV-beständig": "ja",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform (Faber)": "RMv",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSY/",
|
|
||||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
|
||||||
"technischeDaten": {
|
|
||||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
|
||||||
"Norm": "VDE 0276-620",
|
|
||||||
"Leitermaterial": "Aluminium",
|
|
||||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
|
||||||
"Aderisolation": "VPE DIX8",
|
|
||||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
|
||||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
|
||||||
"Mantelmaterial": "PVC DMV6",
|
|
||||||
"Mantelfarbe": "rot",
|
|
||||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
|
||||||
"Als Außenkabel zulässig": "ja",
|
|
||||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
|
||||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
|
||||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
|
||||||
"Leiterform (Faber)": "RMv",
|
|
||||||
"Aderzahl": "1",
|
|
||||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
|
||||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
|
||||||
"Maßeinheit": "Meter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import {getRequestConfig} from 'next-intl/server';
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
export default getRequestConfig(async ({requestLocale}) => {
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
// This typically corresponds to the `[locale]` segment
|
|
||||||
let locale = await requestLocale;
|
let locale = await requestLocale;
|
||||||
|
|
||||||
// Ensure that a valid locale is used
|
// Ensure that a valid locale is used
|
||||||
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
|
|||||||
}
|
}
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
},
|
},
|
||||||
getMessageFallback({namespace, key, error}) {
|
getMessageFallback({ namespace, key, error }) {
|
||||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||||
if (error.code === 'MISSING_MESSAGE') {
|
if (error.code === 'MISSING_MESSAGE') {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return 'fallback';
|
return 'fallback';
|
||||||
}
|
}
|
||||||
};
|
} as any;
|
||||||
});
|
});
|
||||||
|
|||||||
38
lib/blog.ts
38
lib/blog.ts
@@ -44,8 +44,8 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
|||||||
|
|
||||||
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 };
|
||||||
|
|||||||
@@ -22,26 +22,26 @@ function createConfig() {
|
|||||||
isStaging: target === 'staging',
|
isStaging: target === 'staging',
|
||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
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.UMAMI_WEBSITE_ID,
|
||||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
// The proxied path used in the frontend
|
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||||
proxyPath: '/stats/script.js',
|
|
||||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
glitchtip: {
|
glitchtip: {
|
||||||
// Use SENTRY_DSN for both server and client (proxied)
|
|
||||||
dsn: env.SENTRY_DSN,
|
dsn: env.SENTRY_DSN,
|
||||||
// The proxied origin used in the frontend
|
|
||||||
proxyPath: '/errors',
|
proxyPath: '/errors',
|
||||||
enabled: Boolean(env.SENTRY_DSN),
|
// On the client, we always enable it (it uses the tunnel / proxy defined in sentry.client.config.ts)
|
||||||
|
// On the server, we only enable it if the DSN is provided.
|
||||||
|
enabled: typeof window !== 'undefined' || Boolean(env.SENTRY_DSN),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -69,6 +69,10 @@ function createConfig() {
|
|||||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||||
proxyPath: '/cms',
|
proxyPath: '/cms',
|
||||||
},
|
},
|
||||||
|
infraCMS: {
|
||||||
|
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||||
|
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
gotify: {
|
gotify: {
|
||||||
url: env.GOTIFY_URL,
|
url: env.GOTIFY_URL,
|
||||||
@@ -137,6 +141,15 @@ export const config = {
|
|||||||
get notifications() {
|
get notifications() {
|
||||||
return getConfig().notifications;
|
return getConfig().notifications;
|
||||||
},
|
},
|
||||||
|
get feedbackEnabled() {
|
||||||
|
return getConfig().feedbackEnabled;
|
||||||
|
},
|
||||||
|
get infraCMS() {
|
||||||
|
return getConfig().infraCMS;
|
||||||
|
},
|
||||||
|
get gatekeeperUrl() {
|
||||||
|
return getConfig().gatekeeperUrl;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,7 +165,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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
|
import { createDirectus, rest, authentication, staticToken, readItems, readCollections } from '@directus/sdk';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { getServerAppServices } from './services/create-services.server';
|
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;
|
||||||
|
|
||||||
|
// Initialize client with authentication plugin
|
||||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,20 +31,48 @@ function formatError(error: any) {
|
|||||||
return 'A system error occurred. Our team has been notified.';
|
return 'A system error occurred. Our team has been notified.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function ensureAuthenticated() {
|
export async function ensureAuthenticated() {
|
||||||
if (token) {
|
if (token) {
|
||||||
client.setToken(token);
|
(client as any).setToken(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we already have a valid session token in memory (for login flow)
|
||||||
|
const existingToken = await (client as any).getToken();
|
||||||
|
if (existingToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (adminEmail && password) {
|
if (adminEmail && password) {
|
||||||
try {
|
if (authPromise) {
|
||||||
await client.login(adminEmail, password);
|
return authPromise;
|
||||||
} catch (e) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
|
||||||
}
|
|
||||||
console.error('Failed to authenticate with Directus:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authPromise = (async () => {
|
||||||
|
try {
|
||||||
|
client.setToken(null as any);
|
||||||
|
await client.login(adminEmail, password);
|
||||||
|
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||||
|
}
|
||||||
|
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
|
||||||
|
if (shouldShowDevErrors && e.errors) {
|
||||||
|
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
|
||||||
|
}
|
||||||
|
// Clear the promise on failure (especially on invalid credentials)
|
||||||
|
// so we can retry on next request if credentials were updated
|
||||||
|
authPromise = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return authPromise;
|
||||||
|
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
|
||||||
|
console.warn('Directus: No token or admin credentials provided.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
119
lib/env.ts
119
lib/env.ts
@@ -8,51 +8,83 @@ 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_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
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()),
|
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
|
// Deploy Target
|
||||||
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
// Gotify
|
// Gotify
|
||||||
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
});
|
// Gatekeeper
|
||||||
|
GATEKEEPER_URL: z.preprocess(
|
||||||
|
preprocessEmptyString,
|
||||||
|
z.string().url().default('http://gatekeeper:3000'),
|
||||||
|
),
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
|
||||||
|
(val) => val === 'true' || val === true,
|
||||||
|
z.boolean().default(false)
|
||||||
|
),
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
|
||||||
|
(val) => val === 'true' || val === true,
|
||||||
|
z.boolean().default(false)
|
||||||
|
),
|
||||||
|
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||||
|
INFRA_DIRECTUS_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>;
|
||||||
|
|
||||||
@@ -65,8 +97,8 @@ export function getRawEnv() {
|
|||||||
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_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
|
||||||
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,
|
||||||
@@ -83,5 +115,10 @@ export function getRawEnv() {
|
|||||||
TARGET: process.env.TARGET,
|
TARGET: process.env.TARGET,
|
||||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||||
|
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
|
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
|
||||||
|
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
|
||||||
|
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
lib/mdx.ts
76
lib/mdx.ts
@@ -18,6 +18,56 @@ 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);
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +134,7 @@ export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|||||||
29
lib/pages.ts
29
lib/pages.ts
@@ -43,19 +43,38 @@ export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
|||||||
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,6 @@
|
|||||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||||
|
import { config } from '../../config';
|
||||||
/**
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
* 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 +12,162 @@ 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)
|
* On the server, it sends directly to the internal Umami API.
|
||||||
* 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;
|
||||||
|
private logger: LoggerService;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly options: UmamiAnalyticsServiceOptions,
|
||||||
|
logger: LoggerService,
|
||||||
|
) {
|
||||||
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
|
this.logger = logger.child({ component: 'analytics-umami' });
|
||||||
|
|
||||||
|
// On server, use the full internal URL; on client, use the proxied path
|
||||||
|
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
||||||
|
|
||||||
|
this.logger.debug('Umami service initialized', {
|
||||||
|
enabled: this.options.enabled,
|
||||||
|
websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)',
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track a custom event with optional properties.
|
* Set the server-side context for the current request.
|
||||||
*
|
* This allows the service to use real request headers for tracking.
|
||||||
* This method checks if analytics are enabled and if we're in a browser environment
|
|
||||||
* before attempting to track the event.
|
|
||||||
*
|
|
||||||
* @param eventName - The name of the event to track
|
|
||||||
* @param props - Optional event properties
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* service.track('product_add_to_cart', {
|
|
||||||
* product_id: '123',
|
|
||||||
* product_name: 'Cable',
|
|
||||||
* price: 99.99,
|
|
||||||
* quantity: 1,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
setServerContext(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
this.serverContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to send the payload to Umami API.
|
||||||
|
*/
|
||||||
|
private async sendPayload(type: 'event', data: Record<string, any>) {
|
||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
// Server-side tracking via proxy
|
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
|
||||||
if (typeof window === 'undefined') {
|
// On the server, we need it because we're calling the Umami API directly.
|
||||||
const { getServerAppServices } = require('../create-services.server');
|
const isClient = typeof window !== 'undefined';
|
||||||
const { config } = require('../../config');
|
|
||||||
const websiteId = config.analytics.umami.websiteId;
|
|
||||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
|
||||||
|
|
||||||
if (!websiteId) return;
|
if (!isClient && !this.websiteId) {
|
||||||
|
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
try {
|
||||||
umami?.track?.(eventName, props);
|
const payload = {
|
||||||
|
website: this.websiteId,
|
||||||
|
hostname: isClient ? window.location.hostname : 'server',
|
||||||
|
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
||||||
|
language: isClient ? navigator.language : this.serverContext?.language,
|
||||||
|
referrer: isClient ? document.referrer : this.serverContext?.referrer,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.trace('Sending analytics payload', { type, url: data.url });
|
||||||
|
|
||||||
|
// Add a timeout to prevent hanging requests
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set User-Agent
|
||||||
|
if (isClient) {
|
||||||
|
headers['User-Agent'] = navigator.userAgent;
|
||||||
|
} else if (this.serverContext?.userAgent) {
|
||||||
|
headers['User-Agent'] = this.serverContext.userAgent;
|
||||||
|
} else {
|
||||||
|
headers['User-Agent'] = 'KLZ-Server-Proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward client IP if available (Umami must be configured to trust this)
|
||||||
|
if (this.serverContext?.ip) {
|
||||||
|
headers['X-Forwarded-For'] = this.serverContext.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger.warn('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if ((fetchError as Error).name === 'AbortError') {
|
||||||
|
this.logger.error('Umami request timed out');
|
||||||
|
} else {
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to send analytics', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event.
|
||||||
|
*/
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||||
|
this.sendPayload('event', {
|
||||||
|
name: eventName,
|
||||||
|
data: props,
|
||||||
|
url:
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const analytics = config.analytics.umami.enabled
|
const analytics = config.analytics.umami.enabled
|
||||||
? new UmamiAnalyticsService({ enabled: true })
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (config.analytics.umami.enabled) {
|
if (config.analytics.umami.enabled) {
|
||||||
@@ -55,7 +55,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppServices } from './app-services';
|
import { AppServices } from './app-services';
|
||||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||||
|
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';
|
||||||
@@ -28,9 +29,8 @@ 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
|
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
|
||||||
*
|
*
|
||||||
* @returns {AppServices} The application services singleton
|
* @returns {AppServices} The application services singleton
|
||||||
*
|
*
|
||||||
@@ -100,12 +100,8 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create analytics service (Umami or no-op)
|
// Create analytics service (Umami or no-op)
|
||||||
// Use dynamic import to avoid importing server-only code in client components
|
|
||||||
const analytics = umamiEnabled
|
const analytics = umamiEnabled
|
||||||
? (() => {
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
|
|
||||||
return new UmamiAnalyticsService({ enabled: true });
|
|
||||||
})()
|
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (umamiEnabled) {
|
if (umamiEnabled) {
|
||||||
@@ -114,9 +110,13 @@ export function getAppServices(): AppServices {
|
|||||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create notification service
|
||||||
|
const notifications = new NoopNotificationService();
|
||||||
|
logger.info('Notification service initialized (noop)');
|
||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true })
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
@@ -139,7 +139,6 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create and cache the singleton
|
// Create and cache the singleton
|
||||||
const notifications = new NoopNotificationService();
|
|
||||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ErrorReportingUser,
|
ErrorReportingUser,
|
||||||
} from './error-reporting-service';
|
} from './error-reporting-service';
|
||||||
import type { NotificationService } from '../notifications/notification-service';
|
import type { NotificationService } from '../notifications/notification-service';
|
||||||
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
|
|
||||||
type SentryLike = typeof Sentry;
|
type SentryLike = typeof Sentry;
|
||||||
|
|
||||||
@@ -14,11 +15,16 @@ export type GlitchtipErrorReportingServiceOptions = {
|
|||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
|
private logger: LoggerService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
|
logger: LoggerService,
|
||||||
private readonly notifications?: NotificationService,
|
private readonly notifications?: NotificationService,
|
||||||
private readonly sentry: SentryLike = Sentry,
|
private readonly sentry: SentryLike = Sentry,
|
||||||
) {}
|
) {
|
||||||
|
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||||
|
}
|
||||||
|
|
||||||
async 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;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
// Create the internationalization middleware
|
|
||||||
const intlMiddleware = createMiddleware({
|
|
||||||
// A list of all locales that are supported
|
|
||||||
locales: ['en', 'de'],
|
|
||||||
|
|
||||||
// Used when no locale matches
|
|
||||||
defaultLocale: 'en'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main middleware that logs all requests
|
|
||||||
export default function middleware(request: NextRequest) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
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
|
|
||||||
console.log(`Incoming request: method=${method} url=${url}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Apply internationalization middleware
|
|
||||||
const response = intlMiddleware(request);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Request failed: method=${method} url=${url}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
// Match only internationalized pathnames
|
|
||||||
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*']
|
|
||||||
};
|
|
||||||
1
mintel-feedback-vendor
Symbolic link
1
mintel-feedback-vendor
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../at-mintel/packages/next-feedback
|
||||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import './.next/types/routes.d.ts';
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
outputFileTracingRoot: path.join(__dirname, '..'),
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
// Blog redirects
|
// Blog redirects
|
||||||
@@ -170,7 +175,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/posts/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project.html',
|
source: '/posts/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project.html',
|
||||||
destination: '/en/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
destination: '/de/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -322,22 +327,9 @@ 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 directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||||
const glitchtipUrl = process.env.SENTRY_DSN
|
|
||||||
? new URL(process.env.SENTRY_DSN).origin
|
|
||||||
: 'https://errors.infra.mintel.me';
|
|
||||||
|
|
||||||
const directusUrl = process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: '/stats/:path*',
|
|
||||||
destination: `${umamiUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/errors/:path*',
|
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: '/cms/:path*',
|
source: '/cms/:path*',
|
||||||
destination: `${directusUrl}/:path*`,
|
destination: `${directusUrl}/:path*`,
|
||||||
|
|||||||
23047
package-lock.json
generated
23047
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^18.0.3",
|
"@directus/sdk": "^18.0.3",
|
||||||
|
"@mintel/mail": "^1.6.0",
|
||||||
"@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": "^10.38.0",
|
||||||
"@swc/helpers": "^0.5.18",
|
"@swc/helpers": "^0.5.18",
|
||||||
"@types/cheerio": "^0.22.35",
|
"@types/cheerio": "^0.22.35",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
@@ -12,20 +13,21 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.27.1",
|
"framer-motion": "^12.27.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"@mintel/next-feedback": "^1.6.0",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^14.2.35",
|
"next": "16.1.6",
|
||||||
"next-i18next": "^15.4.3",
|
"next-i18next": "^15.4.3",
|
||||||
"next-intl": "^4.6.1",
|
"next-intl": "^4.8.2",
|
||||||
"next-mdx-remote": "^5.0.0",
|
"next-mdx-remote": "^5.0.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
@@ -34,7 +36,9 @@
|
|||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"require-in-the-middle": "^8.0.1",
|
||||||
|
"import-in-the-middle": "^1.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.4.0",
|
"@commitlint/cli": "^20.4.0",
|
||||||
@@ -49,9 +53,8 @@
|
|||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-next": "14.2.35",
|
"@mintel/eslint-config": "^1.6.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -65,23 +68,32 @@
|
|||||||
"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 app directus directus-db",
|
"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 gatekeeper",
|
||||||
"dev:local": "next dev",
|
"dev:local": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"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",
|
||||||
"directus:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
|
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
|
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
|
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
|
"cms:bootstrap": "npm run cms:branding:local",
|
||||||
"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:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||||
"directus:pull:staging": "./scripts/sync-directus.sh pull staging",
|
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||||
"directus:push:testing": "./scripts/sync-directus.sh push testing",
|
"cms:schema:apply:testing": "./scripts/cms-apply.sh testing",
|
||||||
"directus:pull:testing": "./scripts/sync-directus.sh pull testing",
|
"cms:schema:apply:staging": "./scripts/cms-apply.sh staging",
|
||||||
"directus:push:prod": "./scripts/sync-directus.sh push production",
|
"cms:schema:apply:prod": "./scripts/cms-apply.sh production",
|
||||||
"directus:pull:prod": "./scripts/sync-directus.sh pull production",
|
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
|
||||||
|
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
||||||
|
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
|
||||||
|
"cms:push:staging:DANGER": "./scripts/sync-directus.sh push staging",
|
||||||
|
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
||||||
|
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push 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"
|
||||||
|
|||||||
14009
pnpm-lock.yaml
generated
Normal file
14009
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
proxy.ts
Normal file
66
proxy.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// Create the internationalization middleware
|
||||||
|
const intlMiddleware = createMiddleware({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ['en', 'de'],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function middleware(request: NextRequest) {
|
||||||
|
const { method, url, headers } = request;
|
||||||
|
|
||||||
|
// Build header object for logging
|
||||||
|
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; // Don't rewrite hostname yet as it breaks internal fetches in dev
|
||||||
|
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Apply internationalization middleware
|
||||||
|
const response = intlMiddleware(effectiveRequest);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Request failed: method=${method} url=${url} headers=${JSON.stringify(headerObj)}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match only internationalized pathnames
|
||||||
|
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,268 +0,0 @@
|
|||||||
# Migrating Analytics from Independent Analytics to Umami
|
|
||||||
|
|
||||||
This guide explains how to migrate your analytics data from the Independent Analytics WordPress plugin to Umami.
|
|
||||||
|
|
||||||
## What You Have
|
|
||||||
|
|
||||||
You have exported your analytics data from Independent Analytics:
|
|
||||||
- **data/pages(1).csv** - Page-level analytics data with:
|
|
||||||
- Title, Visitors, Views, View Duration, Bounce Rate, URL, Page Type
|
|
||||||
- 220 pages with historical data
|
|
||||||
|
|
||||||
## What You Need
|
|
||||||
|
|
||||||
Before migrating, you need:
|
|
||||||
1. **Umami instance** running (self-hosted or cloud)
|
|
||||||
2. **Website ID** from Umami (create a new website in Umami dashboard)
|
|
||||||
3. **Access credentials** for Umami (API key or database access)
|
|
||||||
|
|
||||||
## Migration Options
|
|
||||||
|
|
||||||
The migration script provides three output formats:
|
|
||||||
|
|
||||||
### Option 1: JSON Import (Recommended for API)
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.json \
|
|
||||||
--format json \
|
|
||||||
--site-id YOUR_UMAMI_SITE_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
**Import via API:**
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
https://your-umami-instance.com/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: SQL Import (Direct Database)
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.sql \
|
|
||||||
--format sql \
|
|
||||||
--site-id YOUR_UMAMI_SITE_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
**Import via PostgreSQL:**
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami -f data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: API Payload (Manual Import)
|
|
||||||
```bash
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import-api.json \
|
|
||||||
--format api \
|
|
||||||
--site-id YOUR_UMAMI_SITE_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step-by-Step Migration Guide
|
|
||||||
|
|
||||||
### 1. Prepare Your Umami Instance
|
|
||||||
|
|
||||||
**If self-hosting:**
|
|
||||||
```bash
|
|
||||||
# Clone Umami
|
|
||||||
git clone https://github.com/umami-software/umami.git
|
|
||||||
cd umami
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Set up environment
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your database credentials
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
npm run migrate
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
npm run build
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
**If using Umami Cloud:**
|
|
||||||
1. Sign up at https://umami.is
|
|
||||||
2. Create a new website
|
|
||||||
3. Get your Website ID from the dashboard
|
|
||||||
|
|
||||||
### 2. Run the Migration Script
|
|
||||||
|
|
||||||
Choose one of the migration options above based on your needs.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```bash
|
|
||||||
# Make the script executable
|
|
||||||
chmod +x scripts/migrate-analytics-to-umami.py
|
|
||||||
|
|
||||||
# Run the migration
|
|
||||||
python3 scripts/migrate-analytics-to-umami.py \
|
|
||||||
--input data/pages\(1\).csv \
|
|
||||||
--output data/umami-import.json \
|
|
||||||
--format json \
|
|
||||||
--site-id klz-cables
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Import the Data
|
|
||||||
|
|
||||||
#### Option A: Using Umami API (Recommended)
|
|
||||||
|
|
||||||
1. **Get your API key:**
|
|
||||||
- Go to Umami dashboard → Settings → API Keys
|
|
||||||
- Create a new API key
|
|
||||||
|
|
||||||
2. **Import the data:**
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d @data/umami-import.json \
|
|
||||||
https://your-umami-instance.com/api/import
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Direct Database Import
|
|
||||||
|
|
||||||
1. **Connect to your Umami database:**
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Import the SQL file:**
|
|
||||||
```bash
|
|
||||||
psql -U umami -d umami -f data/umami-import.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify the import:**
|
|
||||||
```sql
|
|
||||||
SELECT COUNT(*) FROM website_event WHERE website_id = 'klz-cables';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Verify the Migration
|
|
||||||
|
|
||||||
1. **Check Umami dashboard:**
|
|
||||||
- Log into Umami
|
|
||||||
- Select your website
|
|
||||||
- View the analytics dashboard
|
|
||||||
|
|
||||||
2. **Verify data:**
|
|
||||||
- Check page views count
|
|
||||||
- Verify top pages
|
|
||||||
- Check visitor counts
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### Data Limitations
|
|
||||||
|
|
||||||
The CSV export from Independent Analytics contains **aggregated data**, not raw event data:
|
|
||||||
- ✅ Page views (total counts)
|
|
||||||
- ✅ Visitor counts
|
|
||||||
- ✅ Average view duration
|
|
||||||
- ❌ Individual user sessions
|
|
||||||
- ❌ Real-time data
|
|
||||||
- ❌ Geographic data
|
|
||||||
- ❌ Referrer data
|
|
||||||
- ❌ Device/browser data
|
|
||||||
|
|
||||||
### What Gets Imported
|
|
||||||
|
|
||||||
The migration script creates **simulated historical data**:
|
|
||||||
- Each page view becomes a separate event
|
|
||||||
- Timestamps are set to current time (for historical data, you'd need to adjust)
|
|
||||||
- Duration is preserved from the average view duration
|
|
||||||
- No session tracking (each view is independent)
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
1. **Start fresh with Umami:**
|
|
||||||
- Let Umami collect new data going forward
|
|
||||||
- Use the migrated data for historical reference only
|
|
||||||
|
|
||||||
2. **Keep the original CSV:**
|
|
||||||
- Store `data/pages(1).csv` as a backup
|
|
||||||
- You can re-import if needed
|
|
||||||
|
|
||||||
3. **Update your website:**
|
|
||||||
- Replace Independent Analytics tracking code with Umami tracking code
|
|
||||||
- Test that Umami is collecting new data
|
|
||||||
|
|
||||||
4. **Monitor for a few days:**
|
|
||||||
- Verify Umami is collecting data correctly
|
|
||||||
- Compare with any remaining Independent Analytics data
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "ModuleNotFoundError: No module named 'csv'"
|
|
||||||
|
|
||||||
**Solution:** Ensure Python 3 is installed:
|
|
||||||
```bash
|
|
||||||
python3 --version
|
|
||||||
# Should be 3.7 or higher
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: "Permission denied" when running script
|
|
||||||
|
|
||||||
**Solution:** Make the script executable:
|
|
||||||
```bash
|
|
||||||
chmod +x scripts/migrate-analytics-to-umami.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: API import fails
|
|
||||||
|
|
||||||
**Solution:** Check:
|
|
||||||
1. API key is correct and has import permissions
|
|
||||||
2. Website ID exists in Umami
|
|
||||||
3. Umami instance is accessible
|
|
||||||
4. JSON format is valid
|
|
||||||
|
|
||||||
### Issue: SQL import fails
|
|
||||||
|
|
||||||
**Solution:** Check:
|
|
||||||
1. Database credentials in `.env`
|
|
||||||
2. Database is running
|
|
||||||
3. Tables exist (run `npm run migrate` first)
|
|
||||||
4. Permissions to insert into `website_event` table
|
|
||||||
|
|
||||||
## Additional Data Migration
|
|
||||||
|
|
||||||
If you have other CSV exports from Independent Analytics (referrers, devices, locations), you can:
|
|
||||||
|
|
||||||
1. **Export additional data** from Independent Analytics:
|
|
||||||
- Referrers
|
|
||||||
- Devices (browsers, OS)
|
|
||||||
- Geographic data
|
|
||||||
- Custom events
|
|
||||||
|
|
||||||
2. **Create custom migration scripts** for each data type
|
|
||||||
|
|
||||||
3. **Contact Umami support** for bulk import assistance
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- **Umami Documentation:** https://umami.is/docs
|
|
||||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
|
||||||
- **Independent Analytics:** https://independentanalytics.com/
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ **Completed:**
|
|
||||||
- Created migration script (`scripts/migrate-analytics-to-umami.py`)
|
|
||||||
- Generated JSON import file (`data/umami-import.json`)
|
|
||||||
- Generated SQL import file (`data/umami-import.sql`)
|
|
||||||
- Created documentation (`scripts/README-migration.md`)
|
|
||||||
|
|
||||||
📊 **Data Migrated:**
|
|
||||||
- 7,634 simulated page view events
|
|
||||||
- 220 unique pages
|
|
||||||
- Historical view counts and durations
|
|
||||||
|
|
||||||
🎯 **Next Steps:**
|
|
||||||
1. Choose your import method (API or SQL)
|
|
||||||
2. Run the migration script
|
|
||||||
3. Import data into Umami
|
|
||||||
4. Verify the migration
|
|
||||||
5. Update your website to use Umami tracking
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import client, { ensureAuthenticated } from '../lib/directus';
|
|
||||||
import { readCollections, deleteCollection } from '@directus/sdk';
|
|
||||||
|
|
||||||
async function cleanup() {
|
|
||||||
await ensureAuthenticated();
|
|
||||||
const collections = await (client as any).request(readCollections());
|
|
||||||
for (const c of collections) {
|
|
||||||
if (!c.collection.startsWith('directus_')) {
|
|
||||||
console.log(`Deleting ${c.collection}...`);
|
|
||||||
try {
|
|
||||||
await (client as any).request(deleteCollection(c.collection));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to delete ${c.collection}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup().catch(console.error);
|
|
||||||
54
scripts/cms-apply.sh
Executable file
54
scripts/cms-apply.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
ENV=$1
|
||||||
|
REMOTE_HOST="root@alpha.mintel.me"
|
||||||
|
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
|
|
||||||
|
if [ -z "$ENV" ]; then
|
||||||
|
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
|
||||||
|
|
||||||
|
case $ENV in
|
||||||
|
local)
|
||||||
|
CONTAINER=$(docker compose ps -q directus)
|
||||||
|
if [ -z "$CONTAINER" ]; then
|
||||||
|
echo "❌ Local directus container not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "🚀 Applying schema locally..."
|
||||||
|
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||||
|
;;
|
||||||
|
testing|staging|production)
|
||||||
|
case $ENV in
|
||||||
|
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||||
|
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||||
|
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "📤 Uploading snapshot to $ENV..."
|
||||||
|
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
|
||||||
|
|
||||||
|
echo "🔍 Detecting remote container..."
|
||||||
|
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||||
|
echo "❌ Remote container for $ENV not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Applying schema to $ENV..."
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
|
||||||
|
|
||||||
|
echo "🔄 Restarting Directus to clear cache..."
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Invalid environment."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "✨ Schema apply complete!"
|
||||||
15
scripts/cms-snapshot.sh
Executable file
15
scripts/cms-snapshot.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Detect local container
|
||||||
|
LOCAL_CONTAINER=$(docker compose ps -q directus)
|
||||||
|
|
||||||
|
if [ -z "$LOCAL_CONTAINER" ]; then
|
||||||
|
echo "❌ Local directus container not found. Is it running?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📸 Creating schema snapshot..."
|
||||||
|
# Note: we save it to the mounted volume path inside the container
|
||||||
|
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
|
||||||
|
|
||||||
|
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual Translation Mapping Generator
|
|
||||||
* Creates translationKey mappings for posts that couldn't be auto-detected
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync, writeFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
interface Post {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
title: { rendered: string };
|
|
||||||
date: string;
|
|
||||||
lang: string;
|
|
||||||
pll_translation_id?: number;
|
|
||||||
pll_master_post_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TranslationMapping {
|
|
||||||
posts: Record<string, string[]>; // translationKey -> [en_id, de_id]
|
|
||||||
products: Record<string, string[]>;
|
|
||||||
pages: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawData {
|
|
||||||
posts: {
|
|
||||||
en: Post[];
|
|
||||||
de: Post[];
|
|
||||||
};
|
|
||||||
products: {
|
|
||||||
en: any[];
|
|
||||||
de: any[];
|
|
||||||
};
|
|
||||||
pages: {
|
|
||||||
en: any[];
|
|
||||||
de: any[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple text similarity function
|
|
||||||
function calculateSimilarity(text1: string, text2: string): number {
|
|
||||||
const normalize = (str: string) =>
|
|
||||||
str.toLowerCase()
|
|
||||||
.replace(/[^\w\s]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const s1 = normalize(text1);
|
|
||||||
const s2 = normalize(text2);
|
|
||||||
|
|
||||||
if (s1 === s2) return 1.0;
|
|
||||||
|
|
||||||
// Simple overlap calculation
|
|
||||||
const words1 = s1.split(' ');
|
|
||||||
const words2 = s2.split(' ');
|
|
||||||
const intersection = words1.filter(w => words2.includes(w));
|
|
||||||
const union = new Set([...words1, ...words2]);
|
|
||||||
|
|
||||||
return intersection.length / union.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate translation key from title
|
|
||||||
function generateKeyFromTitle(title: string): string {
|
|
||||||
return title.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPostTranslations(
|
|
||||||
postsEn: Post[],
|
|
||||||
postsDe: Post[]
|
|
||||||
): TranslationMapping['posts'] {
|
|
||||||
const mapping: TranslationMapping['posts'] = {};
|
|
||||||
|
|
||||||
// First pass: try to match by Polylang metadata
|
|
||||||
const deById = new Map(postsDe.map(p => [p.id, p]));
|
|
||||||
const deByTranslationId = new Map(postsDe.map(p => [p.pll_translation_id, p]));
|
|
||||||
|
|
||||||
for (const enPost of postsEn) {
|
|
||||||
// Try by pll_translation_id
|
|
||||||
if (enPost.pll_translation_id && deByTranslationId.has(enPost.pll_translation_id)) {
|
|
||||||
const dePost = deByTranslationId.get(enPost.pll_translation_id)!;
|
|
||||||
const key = `post-${enPost.pll_translation_id}`;
|
|
||||||
mapping[key] = [enPost.id, dePost.id];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try by pll_master_post_id
|
|
||||||
if (enPost.pll_master_post_id && deById.has(enPost.pll_master_post_id)) {
|
|
||||||
const dePost = deById.get(enPost.pll_master_post_id)!;
|
|
||||||
const key = `post-${enPost.pll_master_post_id}`;
|
|
||||||
mapping[key] = [enPost.id, dePost.id];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: content-based matching for remaining unmatched posts
|
|
||||||
const matchedEnIds = new Set(
|
|
||||||
Object.values(mapping).flat()
|
|
||||||
);
|
|
||||||
|
|
||||||
const unmatchedEn = postsEn.filter(p => !matchedEnIds.includes(p.id));
|
|
||||||
const unmatchedDe = postsDe.filter(p => !matchedEnIds.includes(p.id));
|
|
||||||
|
|
||||||
for (const enPost of unmatchedEn) {
|
|
||||||
let bestMatch: { post: Post; score: number } | null = null;
|
|
||||||
|
|
||||||
for (const dePost of unmatchedDe) {
|
|
||||||
const titleScore = calculateSimilarity(enPost.title.rendered, dePost.title.rendered);
|
|
||||||
const slugScore = calculateSimilarity(enPost.slug, dePost.slug);
|
|
||||||
const dateScore = enPost.date === dePost.date ? 1.0 : 0.0;
|
|
||||||
|
|
||||||
// Weighted average
|
|
||||||
const score = (titleScore * 0.6) + (slugScore * 0.3) + (dateScore * 0.1);
|
|
||||||
|
|
||||||
if (score > 0.7 && (!bestMatch || score > bestMatch.score)) {
|
|
||||||
bestMatch = { post: dePost, score };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestMatch) {
|
|
||||||
const key = generateKeyFromTitle(enPost.title.rendered);
|
|
||||||
mapping[key] = [enPost.id, bestMatch.post.id];
|
|
||||||
unmatchedDe.splice(unmatchedDe.indexOf(bestMatch.post), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findProductTranslations(
|
|
||||||
productsEn: any[],
|
|
||||||
productsDe: any[]
|
|
||||||
): TranslationMapping['products'] {
|
|
||||||
const mapping: TranslationMapping['products'] = {};
|
|
||||||
|
|
||||||
// Use SKU as primary key if available
|
|
||||||
const deBySku = new Map(productsDe.map(p => [p.sku, p]));
|
|
||||||
|
|
||||||
for (const enProduct of productsEn) {
|
|
||||||
if (enProduct.sku && deBySku.has(enProduct.sku)) {
|
|
||||||
const key = `product-${enProduct.sku}`;
|
|
||||||
mapping[key] = [enProduct.id, deBySku.get(enProduct.sku)!.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPageTranslations(
|
|
||||||
pagesEn: any[],
|
|
||||||
pagesDe: any[]
|
|
||||||
): TranslationMapping['pages'] {
|
|
||||||
const mapping: TranslationMapping['pages'] = {};
|
|
||||||
|
|
||||||
// Pages should have better Polylang metadata
|
|
||||||
const deById = new Map(pagesDe.map(p => [p.id, p]));
|
|
||||||
const deByTranslationId = new Map(pagesDe.map(p => [p.pll_translation_id, p]));
|
|
||||||
|
|
||||||
for (const enPage of pagesEn) {
|
|
||||||
if (enPage.pll_translation_id && deByTranslationId.has(enPage.pll_translation_id)) {
|
|
||||||
const dePage = deByTranslationId.get(enPage.pll_translation_id)!;
|
|
||||||
const key = `page-${enPage.pll_translation_id}`;
|
|
||||||
mapping[key] = [enPage.id, dePage.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
console.log('🔍 Creating manual translation mapping...\n');
|
|
||||||
|
|
||||||
// Read raw data
|
|
||||||
const rawData: RawData = {
|
|
||||||
posts: {
|
|
||||||
en: JSON.parse(readFileSync('data/raw/posts.en.json', 'utf8')),
|
|
||||||
de: JSON.parse(readFileSync('data/raw/posts.de.json', 'utf8'))
|
|
||||||
},
|
|
||||||
products: {
|
|
||||||
en: JSON.parse(readFileSync('data/raw/products.en.json', 'utf8')),
|
|
||||||
de: JSON.parse(readFileSync('data/raw/products.de.json', 'utf8'))
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
en: JSON.parse(readFileSync('data/raw/pages.en.json', 'utf8')),
|
|
||||||
de: JSON.parse(readFileSync('data/raw/pages.de.json', 'utf8'))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📊 Raw data loaded:');
|
|
||||||
console.log(` - Posts: ${rawData.posts.en.length} EN, ${rawData.posts.de.length} DE`);
|
|
||||||
console.log(` - Products: ${rawData.products.en.length} EN, ${rawData.products.de.length} DE`);
|
|
||||||
console.log(` - Pages: ${rawData.pages.en.length} EN, ${rawData.pages.de.length} DE`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Generate mappings
|
|
||||||
const mapping: TranslationMapping = {
|
|
||||||
posts: findPostTranslations(rawData.posts.en, rawData.posts.de),
|
|
||||||
products: findProductTranslations(rawData.products.en, rawData.products.de),
|
|
||||||
pages: findPageTranslations(rawData.pages.en, rawData.pages.de)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save mapping
|
|
||||||
const outputPath = 'data/manual-translation-mapping.json';
|
|
||||||
writeFileSync(outputPath, JSON.stringify(mapping, null, 2));
|
|
||||||
|
|
||||||
console.log('✅ Manual translation mapping created:\n');
|
|
||||||
console.log(`Posts: ${Object.keys(mapping.posts).length} pairs`);
|
|
||||||
console.log(`Products: ${Object.keys(mapping.products).length} pairs`);
|
|
||||||
console.log(`Pages: ${Object.keys(mapping.pages).length} pairs`);
|
|
||||||
console.log(`\nSaved to: ${outputPath}`);
|
|
||||||
|
|
||||||
// Show some examples
|
|
||||||
if (Object.keys(mapping.posts).length > 0) {
|
|
||||||
console.log('\n📝 Post mapping examples:');
|
|
||||||
Object.entries(mapping.posts).slice(0, 3).forEach(([key, ids]) => {
|
|
||||||
const enPost = rawData.posts.en.find(p => p.id === ids[0]);
|
|
||||||
const dePost = rawData.posts.de.find(p => p.id === ids[1]);
|
|
||||||
console.log(` ${key}:`);
|
|
||||||
console.log(` EN: [${ids[0]}] ${enPost?.title.rendered}`);
|
|
||||||
console.log(` DE: [${ids[1]}] ${dePost?.title.rendered}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Deploy analytics data to your Umami instance on alpha.mintel.me
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration - Umami is on infra.mintel.me
|
|
||||||
SERVER="root@infra.mintel.me"
|
|
||||||
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
|
|
||||||
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
|
|
||||||
|
|
||||||
# Umami API endpoint (assuming it's running on the same server)
|
|
||||||
UMAMI_API="http://localhost:3000/api/import"
|
|
||||||
|
|
||||||
echo "🚀 Deploying analytics data to your Umami instance..."
|
|
||||||
echo "Server: $SERVER"
|
|
||||||
echo "Remote path: $REMOTE_PATH"
|
|
||||||
echo "Website ID: $WEBSITE_ID"
|
|
||||||
echo "Umami API: $UMAMI_API"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if files exist
|
|
||||||
if [ ! -f "data/umami-import.json" ]; then
|
|
||||||
echo "❌ Error: data/umami-import.json not found"
|
|
||||||
echo "Please run the migration script first:"
|
|
||||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test SSH connection
|
|
||||||
echo "🔍 Testing SSH connection to $SERVER..."
|
|
||||||
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
|
|
||||||
echo "❌ Error: Cannot connect to $SERVER"
|
|
||||||
echo "Please check your SSH key and connection"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ SSH connection successful"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create directory and copy files to server
|
|
||||||
echo "📁 Creating remote directory..."
|
|
||||||
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
|
|
||||||
echo "✅ Remote directory created"
|
|
||||||
|
|
||||||
echo "📤 Copying analytics files to server..."
|
|
||||||
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
|
|
||||||
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
|
|
||||||
echo "✅ Files copied successfully"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Detect Umami container
|
|
||||||
echo "🔍 Detecting Umami container..."
|
|
||||||
UMAMI_CONTAINER=$(ssh "$SERVER" "docker ps -q --filter 'name=umami'")
|
|
||||||
if [ -z "$UMAMI_CONTAINER" ]; then
|
|
||||||
echo "❌ Error: Could not detect Umami container"
|
|
||||||
echo "Make sure Umami is running on $SERVER"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Umami container detected: $UMAMI_CONTAINER"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Import data via database (most reliable method)
|
|
||||||
echo "📥 Importing data via database..."
|
|
||||||
ssh "$SERVER" "
|
|
||||||
echo 'Importing data into Umami database...'
|
|
||||||
docker exec -i core-postgres-1 psql -U infra -d umami < $REMOTE_PATH/data/umami-import.sql
|
|
||||||
echo '✅ Database import completed'
|
|
||||||
"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Migration Complete!"
|
|
||||||
echo ""
|
|
||||||
echo "Your analytics data has been imported into Umami."
|
|
||||||
echo "Website ID: $WEBSITE_ID"
|
|
||||||
echo ""
|
|
||||||
echo "Verify in Umami dashboard: https://analytics.infra.mintel.me"
|
|
||||||
echo "You should see 7,634 historical page view events."
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Deploy analytics data to Umami server
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SERVER="root@alpha.mintel.me"
|
|
||||||
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
|
|
||||||
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
|
|
||||||
|
|
||||||
echo "🚀 Deploying analytics data to Umami server..."
|
|
||||||
echo "Server: $SERVER"
|
|
||||||
echo "Remote path: $REMOTE_PATH"
|
|
||||||
echo "Website ID: $WEBSITE_ID"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if files exist
|
|
||||||
if [ ! -f "data/umami-import.json" ]; then
|
|
||||||
echo "❌ Error: data/umami-import.json not found"
|
|
||||||
echo "Please run the migration script first:"
|
|
||||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "data/umami-import.sql" ]; then
|
|
||||||
echo "❌ Error: data/umami-import.sql not found"
|
|
||||||
echo "Please run the migration script first:"
|
|
||||||
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.sql --format sql --site-id $WEBSITE_ID"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if SSH connection works
|
|
||||||
echo "🔍 Testing SSH connection..."
|
|
||||||
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
|
|
||||||
echo "❌ Error: Cannot connect to $SERVER"
|
|
||||||
echo "Please check your SSH key and connection"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ SSH connection successful"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create remote directory if it doesn't exist
|
|
||||||
echo "📁 Creating remote directory..."
|
|
||||||
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
|
|
||||||
echo "✅ Remote directory created"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Copy files to server
|
|
||||||
echo "📤 Copying files to server..."
|
|
||||||
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
|
|
||||||
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
|
|
||||||
echo "✅ Files copied successfully"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Option 1: Import via API (if Umami API is accessible)
|
|
||||||
echo "📋 Import Options:"
|
|
||||||
echo ""
|
|
||||||
echo "Option 1: Import via API (Recommended)"
|
|
||||||
echo "--------------------------------------"
|
|
||||||
echo "1. SSH into your server:"
|
|
||||||
echo " ssh $SERVER"
|
|
||||||
echo ""
|
|
||||||
echo "2. Navigate to the directory:"
|
|
||||||
echo " cd $REMOTE_PATH"
|
|
||||||
echo ""
|
|
||||||
echo "3. Get your Umami API key:"
|
|
||||||
echo " - Log into Umami dashboard"
|
|
||||||
echo " - Go to Settings → API Keys"
|
|
||||||
echo " - Create a new API key"
|
|
||||||
echo ""
|
|
||||||
echo "4. Import the data:"
|
|
||||||
echo " curl -X POST \\"
|
|
||||||
echo " -H \"Content-Type: application/json\" \\"
|
|
||||||
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
|
|
||||||
echo " -d @data/umami-import.json \\"
|
|
||||||
echo " http://localhost:3000/api/import"
|
|
||||||
echo ""
|
|
||||||
echo " Or if Umami is on a different port/domain:"
|
|
||||||
echo " curl -X POST \\"
|
|
||||||
echo " -H \"Content-Type: application/json\" \\"
|
|
||||||
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
|
|
||||||
echo " -d @data/umami-import.json \\"
|
|
||||||
echo " https://your-umami-domain.com/api/import"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Option 2: Import via Database
|
|
||||||
echo "Option 2: Import via Database"
|
|
||||||
echo "------------------------------"
|
|
||||||
echo "1. SSH into your server:"
|
|
||||||
echo " ssh $SERVER"
|
|
||||||
echo ""
|
|
||||||
echo "2. Navigate to the directory:"
|
|
||||||
echo " cd $REMOTE_PATH"
|
|
||||||
echo ""
|
|
||||||
echo "3. Import the SQL file:"
|
|
||||||
echo " psql -U umami -d umami -f data/umami-import.sql"
|
|
||||||
echo ""
|
|
||||||
echo " If you need to specify host/port:"
|
|
||||||
echo " PGPASSWORD=your_password psql -h localhost -U umami -d umami -f data/umami-import.sql"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Option 3: Manual import via Umami dashboard
|
|
||||||
echo "Option 3: Manual Import via Umami Dashboard"
|
|
||||||
echo "--------------------------------------------"
|
|
||||||
echo "1. Log into Umami dashboard"
|
|
||||||
echo "2. Go to Settings → Import"
|
|
||||||
echo "3. Upload data/umami-import.json"
|
|
||||||
echo "4. Select your website (ID: $WEBSITE_ID)"
|
|
||||||
echo "5. Click Import"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "📊 File Information:"
|
|
||||||
echo "-------------------"
|
|
||||||
echo "JSON file: $(ls -lh data/umami-import.json | awk '{print $5}')"
|
|
||||||
echo "SQL file: $(ls -lh data/umami-import.sql | awk '{print $5}')"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "✅ Deployment complete!"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo "1. Choose one of the import methods above"
|
|
||||||
echo "2. Import the data into Umami"
|
|
||||||
echo "3. Verify the data in Umami dashboard"
|
|
||||||
echo "4. Update your website to use Umami tracking code"
|
|
||||||
echo ""
|
|
||||||
echo "For detailed instructions, see: scripts/README-migration.md"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
const blogDir = path.join(process.cwd(), 'data', 'blog', 'en');
|
|
||||||
const outputDir = path.join(process.cwd(), 'reference', 'klz-cables-clone', 'posts');
|
|
||||||
|
|
||||||
if (!fs.existsSync(outputDir)) {
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(blogDir);
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
if (!file.endsWith('.mdx')) return;
|
|
||||||
|
|
||||||
const slug = file.replace('.mdx', '');
|
|
||||||
const url = `https://klz-cables.com/${slug}/`;
|
|
||||||
const outputPath = path.join(outputDir, `${slug}.html`);
|
|
||||||
|
|
||||||
if (fs.existsSync(outputPath)) {
|
|
||||||
console.log(`Skipping ${slug}, already exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Fetching ${slug}...`);
|
|
||||||
try {
|
|
||||||
execSync(`curl -L -s "${url}" -o "${outputPath}"`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to fetch ${slug}: ${e.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const cheerio = require('cheerio');
|
|
||||||
|
|
||||||
const API_URL = 'https://klz-cables.com/wp-json/wp/v2/posts?per_page=100&_embed';
|
|
||||||
|
|
||||||
async function fetchPosts() {
|
|
||||||
console.log('Fetching posts...');
|
|
||||||
const response = await fetch(API_URL);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch posts: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const posts = await response.json();
|
|
||||||
console.log(`Fetched ${posts.length} posts.`);
|
|
||||||
return posts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanContent(content) {
|
|
||||||
let cleaned = content;
|
|
||||||
|
|
||||||
// Decode HTML entities first to make regex easier
|
|
||||||
cleaned = cleaned.replace(/”/g, '"').replace(/“/g, '"').replace(/’/g, "'").replace(/&/g, '&').replace(/″/g, '"');
|
|
||||||
|
|
||||||
// Remove vc_row and vc_column wrappers
|
|
||||||
cleaned = cleaned.replace(/\[\/?vc_row.*?\]/g, '');
|
|
||||||
cleaned = cleaned.replace(/\[\/?vc_column.*?\]/g, '');
|
|
||||||
|
|
||||||
// Remove vc_column_text wrapper but keep content
|
|
||||||
cleaned = cleaned.replace(/\[vc_column_text.*?\]/g, '');
|
|
||||||
cleaned = cleaned.replace(/\[\/vc_column_text\]/g, '');
|
|
||||||
|
|
||||||
// Convert split_line_heading to h2
|
|
||||||
cleaned = cleaned.replace(/\[split_line_heading[^\]]*text_content="([^"]+)"[^\]]*\](?:\[\/split_line_heading\])?/g, '<h2>$1</h2>');
|
|
||||||
|
|
||||||
// Remove other shortcodes
|
|
||||||
cleaned = cleaned.replace(/\[image_with_animation.*?\]/g, '');
|
|
||||||
cleaned = cleaned.replace(/\[divider.*?\]/g, '');
|
|
||||||
cleaned = cleaned.replace(/\[nectar_global_section.*?\]/g, '');
|
|
||||||
|
|
||||||
// Use Cheerio for HTML manipulation
|
|
||||||
const $ = cheerio.load(cleaned, { xmlMode: false, decodeEntities: false });
|
|
||||||
|
|
||||||
// Convert VisualLinkPreview
|
|
||||||
$('.vlp-link-container').each((i, el) => {
|
|
||||||
const $el = $(el);
|
|
||||||
const url = $el.find('a.vlp-link').attr('href');
|
|
||||||
const title = $el.find('.vlp-link-title').text().trim() || $el.find('a.vlp-link').attr('title');
|
|
||||||
const image = $el.find('.vlp-link-image img').attr('src');
|
|
||||||
const summary = $el.find('.vlp-link-summary').text().trim();
|
|
||||||
|
|
||||||
if (url && title) {
|
|
||||||
// We use a placeholder to avoid Cheerio messing up the React component syntax
|
|
||||||
const component = `__VISUAL_LINK_PREVIEW_START__ url="${url}" title="${title}" image="${image || ''}" summary="${summary || ''}" __VISUAL_LINK_PREVIEW_END__`;
|
|
||||||
$el.replaceWith(component);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove data attributes
|
|
||||||
$('*').each((i, el) => {
|
|
||||||
const attribs = el.attribs;
|
|
||||||
for (const name in attribs) {
|
|
||||||
if (name.startsWith('data-')) {
|
|
||||||
$(el).removeAttr(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unwrap divs (remove div tags but keep content)
|
|
||||||
$('div').each((i, el) => {
|
|
||||||
$(el).replaceWith($(el).html());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove empty paragraphs
|
|
||||||
$('p').each((i, el) => {
|
|
||||||
if ($(el).text().trim() === '' && $(el).children().length === 0) {
|
|
||||||
$(el).remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = $('body').html() || '';
|
|
||||||
|
|
||||||
// Restore VisualLinkPreview
|
|
||||||
output = output.replace(/__VISUAL_LINK_PREVIEW_START__/g, '<VisualLinkPreview').replace(/__VISUAL_LINK_PREVIEW_END__/g, '/>');
|
|
||||||
|
|
||||||
return output.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMdx(post) {
|
|
||||||
const title = post.title.rendered.replace(/”/g, '"').replace(/“/g, '"').replace(/’/g, "'").replace(/&/g, '&');
|
|
||||||
const date = post.date;
|
|
||||||
const slug = post.slug;
|
|
||||||
const lang = post.lang || 'en'; // Default to en if not specified
|
|
||||||
|
|
||||||
let featuredImage = '';
|
|
||||||
if (post._embedded && post._embedded['wp:featuredmedia'] && post._embedded['wp:featuredmedia'][0]) {
|
|
||||||
featuredImage = post._embedded['wp:featuredmedia'][0].source_url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = cleanContent(post.content.rendered);
|
|
||||||
|
|
||||||
return `---
|
|
||||||
title: "${title}"
|
|
||||||
date: '${date}'
|
|
||||||
featuredImage: ${featuredImage}
|
|
||||||
locale: ${lang}
|
|
||||||
---
|
|
||||||
|
|
||||||
${content}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const posts = await fetchPosts();
|
|
||||||
|
|
||||||
for (const post of posts) {
|
|
||||||
const lang = post.lang || 'en';
|
|
||||||
const slug = post.slug;
|
|
||||||
const mdxContent = generateMdx(post);
|
|
||||||
|
|
||||||
const dir = path.join('data/blog', lang);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(dir, `${slug}.mdx`);
|
|
||||||
fs.writeFileSync(filePath, mdxContent);
|
|
||||||
console.log(`Saved ${filePath}`);
|
|
||||||
}
|
|
||||||
console.log('Done.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const blogDir = path.join(process.cwd(), 'data', 'blog');
|
|
||||||
|
|
||||||
function fixFile(filePath) {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const lines = content.split('\n');
|
|
||||||
|
|
||||||
if (lines[0].trim() !== '---') {
|
|
||||||
return; // Not a frontmatter file or already fixed/different format
|
|
||||||
}
|
|
||||||
|
|
||||||
let newLines = [];
|
|
||||||
let inFrontmatter = false;
|
|
||||||
let frontmatterLines = [];
|
|
||||||
let contentLines = [];
|
|
||||||
|
|
||||||
// Separate frontmatter and content
|
|
||||||
if (lines[0].trim() === '---') {
|
|
||||||
inFrontmatter = true;
|
|
||||||
let i = 1;
|
|
||||||
// Skip empty line after first ---
|
|
||||||
if (lines[1].trim() === '') {
|
|
||||||
i = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (; i < lines.length; i++) {
|
|
||||||
if (lines[i].trim() === '---') {
|
|
||||||
inFrontmatter = false;
|
|
||||||
contentLines = lines.slice(i + 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
frontmatterLines.push(lines[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process frontmatter lines to fix multiline strings
|
|
||||||
let fixedFrontmatter = [];
|
|
||||||
for (let i = 0; i < frontmatterLines.length; i++) {
|
|
||||||
let line = frontmatterLines[i];
|
|
||||||
|
|
||||||
// Check for multiline indicator >-
|
|
||||||
if (line.includes('>-')) {
|
|
||||||
const [key, ...rest] = line.split(':');
|
|
||||||
if (rest.join(':').trim() === '>-') {
|
|
||||||
// It's a multiline start
|
|
||||||
let value = '';
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < frontmatterLines.length) {
|
|
||||||
const nextLine = frontmatterLines[j];
|
|
||||||
// If next line is a new key (contains : and doesn't start with space), stop
|
|
||||||
if (nextLine.includes(':') && !nextLine.startsWith(' ')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
value += (value ? ' ' : '') + nextLine.trim();
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
fixedFrontmatter.push(`${key}: '${value.replace(/'/g, "''")}'`);
|
|
||||||
i = j - 1; // Skip processed lines
|
|
||||||
} else {
|
|
||||||
fixedFrontmatter.push(line);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fixedFrontmatter.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newContent = `---\n${fixedFrontmatter.join('\n')}\n---\n${contentLines.join('\n')}`;
|
|
||||||
fs.writeFileSync(filePath, newContent);
|
|
||||||
console.log(`Fixed ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function processDir(dir) {
|
|
||||||
const files = fs.readdirSync(dir);
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(dir, file);
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
processDir(filePath);
|
|
||||||
} else if (file.endsWith('.mdx')) {
|
|
||||||
fixFile(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processDir(blogDir);
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import client, { ensureAuthenticated } from '../lib/directus';
|
|
||||||
import {
|
|
||||||
createCollection,
|
|
||||||
createField,
|
|
||||||
createItem,
|
|
||||||
readCollections,
|
|
||||||
deleteCollection
|
|
||||||
} from '@directus/sdk';
|
|
||||||
|
|
||||||
async function fixSchema() {
|
|
||||||
console.log('🚑 EXTERNAL RESCUE: Fixing Schema & Data...');
|
|
||||||
await ensureAuthenticated();
|
|
||||||
|
|
||||||
// 1. Reset Products Collection to be 100% Standard
|
|
||||||
console.log('🗑️ Clearing broken collections...');
|
|
||||||
try { await client.request(deleteCollection('products')); } catch (e) { }
|
|
||||||
try { await client.request(deleteCollection('products_translations')); } catch (e) { }
|
|
||||||
|
|
||||||
// 2. Create Products (Simple, Standard ID)
|
|
||||||
console.log('🏗️ Rebuilding Products Schema...');
|
|
||||||
await client.request(createCollection({
|
|
||||||
collection: 'products',
|
|
||||||
schema: {}, // Let Directus decide defaults
|
|
||||||
meta: {
|
|
||||||
display_template: '{{sku}}',
|
|
||||||
archive_field: 'status',
|
|
||||||
archive_value: 'archived',
|
|
||||||
unarchive_value: 'published'
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
field: 'id',
|
|
||||||
type: 'integer',
|
|
||||||
schema: { is_primary_key: true, has_auto_increment: true },
|
|
||||||
meta: { hidden: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
type: 'string',
|
|
||||||
schema: { default_value: 'published' },
|
|
||||||
meta: { width: 'full', options: { choices: [{ text: 'Published', value: 'published' }] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'sku',
|
|
||||||
type: 'string',
|
|
||||||
meta: { interface: 'input', width: 'half' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
} as any));
|
|
||||||
|
|
||||||
// 3. Create Translation Relation Safely
|
|
||||||
console.log('🌍 Rebuilding Translations...');
|
|
||||||
await client.request(createCollection({
|
|
||||||
collection: 'products_translations',
|
|
||||||
schema: {},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
field: 'id',
|
|
||||||
type: 'integer',
|
|
||||||
schema: { is_primary_key: true, has_auto_increment: true },
|
|
||||||
meta: { hidden: true }
|
|
||||||
},
|
|
||||||
{ field: 'products_id', type: 'integer' },
|
|
||||||
{ field: 'languages_code', type: 'string' },
|
|
||||||
{ field: 'name', type: 'string', meta: { interface: 'input', width: 'full' } },
|
|
||||||
{ field: 'description', type: 'text', meta: { interface: 'input-multiline' } },
|
|
||||||
{ field: 'technical_items', type: 'json', meta: { interface: 'input-code-json' } }
|
|
||||||
]
|
|
||||||
} as any));
|
|
||||||
|
|
||||||
// 4. Manually Insert ONE Product to Verify
|
|
||||||
console.log('📦 Injecting Test Product...');
|
|
||||||
try {
|
|
||||||
// We do this in two steps to be absolutely sure permissions don't block us
|
|
||||||
// Step A: Create User-Facing Product
|
|
||||||
const product = await client.request(createItem('products', {
|
|
||||||
sku: 'H1Z2Z2-K-TEST',
|
|
||||||
status: 'published'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Step B: Add Translation
|
|
||||||
await client.request(createItem('products_translations', {
|
|
||||||
products_id: product.id,
|
|
||||||
languages_code: 'de-DE',
|
|
||||||
name: 'H1Z2Z2-K Test Cable',
|
|
||||||
description: 'This is a verified imported product.',
|
|
||||||
technical_items: [{ label: 'Test', value: '100%' }]
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`✅ SUCCESS! Product Created with ID: ${product.id}`);
|
|
||||||
console.log(`verify at: ${process.env.DIRECTUS_URL}/admin/content/products/${product.id}`);
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('❌ Failed to create product:', e);
|
|
||||||
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixSchema().catch(console.error);
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migrate Independent Analytics data to Umami format
|
|
||||||
"""
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
import uuid
|
|
||||||
import random
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def parse_view_duration(duration_str):
|
|
||||||
"""Convert view duration from 'X:XX' format to seconds"""
|
|
||||||
if not duration_str or duration_str == '-':
|
|
||||||
return 0
|
|
||||||
|
|
||||||
parts = duration_str.split(':')
|
|
||||||
if len(parts) == 2:
|
|
||||||
return int(parts[0]) * 60 + int(parts[1])
|
|
||||||
elif len(parts) == 3:
|
|
||||||
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def convert_to_umami_format(csv_file, output_file, site_id="your-site-id"):
|
|
||||||
"""
|
|
||||||
Convert Independent Analytics CSV to Umami import format
|
|
||||||
|
|
||||||
Umami expects data in this format for API import:
|
|
||||||
{
|
|
||||||
"website_id": "uuid",
|
|
||||||
"hostname": "example.com",
|
|
||||||
"path": "/path",
|
|
||||||
"referrer": "",
|
|
||||||
"event_name": null,
|
|
||||||
"pageview": true,
|
|
||||||
"session": true,
|
|
||||||
"duration": 0,
|
|
||||||
"created_at": "2024-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
umami_records = []
|
|
||||||
|
|
||||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
# Skip 404 pages and empty entries
|
|
||||||
if row.get('Page Type') == '404' or not row.get('URL'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract data
|
|
||||||
title = row.get('Title', '')
|
|
||||||
url = row.get('URL', '/')
|
|
||||||
visitors = int(row.get('Visitors', 0))
|
|
||||||
views = int(row.get('Views', 0))
|
|
||||||
view_duration = parse_view_duration(row.get('View Duration', '0:00'))
|
|
||||||
bounce_rate = float(row.get('Bounce Rate', '0').strip('%')) if row.get('Bounce Rate') else 0
|
|
||||||
|
|
||||||
# Calculate total session duration (views * average duration)
|
|
||||||
total_duration = views * view_duration
|
|
||||||
|
|
||||||
# Create multiple records for each view to simulate historical data
|
|
||||||
# This is a simplified approach - in reality, you'd want more granular data
|
|
||||||
for i in range(min(views, 100)): # Limit to 100 records per page to avoid huge files
|
|
||||||
umami_record = {
|
|
||||||
"website_id": site_id,
|
|
||||||
"hostname": "your-domain.com", # Update this
|
|
||||||
"path": url,
|
|
||||||
"referrer": "",
|
|
||||||
"event_name": None,
|
|
||||||
"pageview": True,
|
|
||||||
"session": True,
|
|
||||||
"duration": view_duration,
|
|
||||||
"created_at": datetime.now().isoformat() + "Z"
|
|
||||||
}
|
|
||||||
umami_records.append(umami_record)
|
|
||||||
|
|
||||||
# Write to JSON file
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(umami_records, f, indent=2)
|
|
||||||
|
|
||||||
print(f"✅ Converted {len(umami_records)} records to Umami format")
|
|
||||||
print(f"📁 Output saved to: {output_file}")
|
|
||||||
return umami_records
|
|
||||||
|
|
||||||
def generate_sql_import(csv_file, output_file, site_id="your-site-id"):
|
|
||||||
"""
|
|
||||||
Generate SQL statements for direct database import into Umami.
|
|
||||||
Optimized to match target metrics:
|
|
||||||
- Visitors: ~7,639
|
|
||||||
- Views: ~20,718
|
|
||||||
- Sessions: ~9,216
|
|
||||||
- Avg Duration: ~3:41
|
|
||||||
- Bounce Rate: ~61%
|
|
||||||
"""
|
|
||||||
|
|
||||||
sql_statements = []
|
|
||||||
|
|
||||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
rows = [r for r in reader if r.get('Page Type') != '404' and r.get('URL')]
|
|
||||||
|
|
||||||
# Target totals
|
|
||||||
TARGET_VISITORS = 7639
|
|
||||||
TARGET_VIEWS = 20718
|
|
||||||
TARGET_SESSIONS = 9216
|
|
||||||
TARGET_AVG_DURATION = 221 # 3:41 in seconds
|
|
||||||
TARGET_BOUNCE_RATE = 0.61
|
|
||||||
|
|
||||||
# Umami "Visitors" = count(distinct session_id)
|
|
||||||
# Umami "Visits" = count(distinct visit_id)
|
|
||||||
# Umami "Views" = count(*) where event_type = 1
|
|
||||||
|
|
||||||
# To get 7639 Visitors and 9216 Sessions, we need 7639 unique session_ids.
|
|
||||||
# Wait, if Visitors < Sessions, it usually means some visitors had multiple sessions.
|
|
||||||
# But in Umami DB, session_id IS the visitor.
|
|
||||||
# If we want 7639 Visitors, we MUST have exactly 7639 unique session_ids.
|
|
||||||
# If we want 9216 Sessions, we need to understand what Umami calls a "Session" in the UI.
|
|
||||||
# In Umami v2, "Sessions" in the UI often refers to unique visit_id.
|
|
||||||
# Let's aim for:
|
|
||||||
# 7639 unique session_id (Visitors)
|
|
||||||
# 9216 unique visit_id (Sessions/Visits)
|
|
||||||
# 20718 total events (Views)
|
|
||||||
|
|
||||||
session_ids = [str(uuid.uuid4()) for _ in range(TARGET_VISITORS)]
|
|
||||||
|
|
||||||
# Distribute sessions over 30 days
|
|
||||||
# We'll create 9216 "visits" distributed among 7639 "sessions"
|
|
||||||
visits = []
|
|
||||||
for i in range(TARGET_SESSIONS):
|
|
||||||
visit_id = str(uuid.uuid4())
|
|
||||||
sess_id = session_ids[i % len(session_ids)]
|
|
||||||
|
|
||||||
# Distribute over 30 days
|
|
||||||
# Last 7 days target: ~218 visitors, ~249 sessions
|
|
||||||
# 249/9216 = ~2.7% of data in last 7 days.
|
|
||||||
# Let's use a weighted distribution to match the "Last 7 days" feedback.
|
|
||||||
if random.random() < 0.027: # ~2.7% chance for last 7 days
|
|
||||||
days_ago = random.randint(0, 6)
|
|
||||||
else:
|
|
||||||
days_ago = random.randint(7, 30)
|
|
||||||
|
|
||||||
hour = random.randint(0, 23)
|
|
||||||
minute = random.randint(0, 59)
|
|
||||||
start_time = (datetime.now() - timedelta(days=days_ago, hours=hour, minutes=minute))
|
|
||||||
|
|
||||||
visits.append({'sess_id': sess_id, 'visit_id': visit_id, 'time': start_time, 'views': 0})
|
|
||||||
|
|
||||||
# Create the unique sessions in DB
|
|
||||||
for sess_id in session_ids:
|
|
||||||
# Find the earliest visit for this session to use as session created_at
|
|
||||||
sess_time = min([v['time'] for v in visits if v['sess_id'] == sess_id])
|
|
||||||
sql_sess = f"""
|
|
||||||
INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
|
|
||||||
VALUES ('{sess_id}', '{site_id}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '{sess_time.strftime('%Y-%m-%d %H:%M:%S')}')
|
|
||||||
ON CONFLICT (session_id) DO NOTHING;
|
|
||||||
"""
|
|
||||||
sql_statements.append(sql_sess.strip())
|
|
||||||
|
|
||||||
# Distribute 20718 views among 9216 visits
|
|
||||||
views_remaining = TARGET_VIEWS - TARGET_SESSIONS
|
|
||||||
|
|
||||||
# Every visit gets at least 1 view
|
|
||||||
url_pool = []
|
|
||||||
for row in rows:
|
|
||||||
weight = int(row['Views'])
|
|
||||||
url_pool.extend([{'url': row['URL'], 'title': row['Title'].replace("'", "''")}] * weight)
|
|
||||||
random.shuffle(url_pool)
|
|
||||||
url_idx = 0
|
|
||||||
|
|
||||||
for v in visits:
|
|
||||||
url_data = url_pool[url_idx % len(url_pool)]
|
|
||||||
url_idx += 1
|
|
||||||
|
|
||||||
event_id = str(uuid.uuid4())
|
|
||||||
sql_ev = f"""
|
|
||||||
INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
|
||||||
VALUES ('{event_id}', '{site_id}', '{v['sess_id']}', '{v['time'].strftime('%Y-%m-%d %H:%M:%S')}', '{url_data['url']}', '', '', '', '', '{url_data['title']}', 1, NULL, '{v['visit_id']}', 'klz-cables.com');
|
|
||||||
"""
|
|
||||||
sql_statements.append(sql_ev.strip())
|
|
||||||
v['views'] += 1
|
|
||||||
|
|
||||||
# Add remaining views to visits
|
|
||||||
# To match bounce rate, we only add views to (1 - bounce_rate) of visits
|
|
||||||
num_non_bounces = int(TARGET_SESSIONS * (1 - TARGET_BOUNCE_RATE))
|
|
||||||
non_bounce_visits = random.sample(visits, num_non_bounces)
|
|
||||||
|
|
||||||
for _ in range(views_remaining):
|
|
||||||
v = random.choice(non_bounce_visits)
|
|
||||||
url_data = url_pool[url_idx % len(url_pool)]
|
|
||||||
url_idx += 1
|
|
||||||
|
|
||||||
v['views'] += 1
|
|
||||||
# Add duration
|
|
||||||
view_time = v['time'] + timedelta(seconds=random.randint(30, 300))
|
|
||||||
|
|
||||||
event_id = str(uuid.uuid4())
|
|
||||||
sql_ev = f"""
|
|
||||||
INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
|
|
||||||
VALUES ('{event_id}', '{site_id}', '{v['sess_id']}', '{view_time.strftime('%Y-%m-%d %H:%M:%S')}', '{url_data['url']}', '', '', '', '', '{url_data['title']}', 1, NULL, '{v['visit_id']}', 'klz-cables.com');
|
|
||||||
"""
|
|
||||||
sql_statements.append(sql_ev.strip())
|
|
||||||
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(sql_statements))
|
|
||||||
|
|
||||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
|
||||||
print(f"📁 Output saved to: {output_file}")
|
|
||||||
return sql_statements
|
|
||||||
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(sql_statements))
|
|
||||||
|
|
||||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
|
||||||
print(f"📁 Output saved to: {output_file}")
|
|
||||||
return sql_statements
|
|
||||||
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(sql_statements))
|
|
||||||
|
|
||||||
print(f"✅ Generated {len(sql_statements)} SQL statements")
|
|
||||||
print(f"📁 Output saved to: {output_file}")
|
|
||||||
return sql_statements
|
|
||||||
|
|
||||||
def generate_api_payload(csv_file, output_file, site_id="your-site-id"):
|
|
||||||
"""
|
|
||||||
Generate payload for Umami API import
|
|
||||||
"""
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"website_id": site_id,
|
|
||||||
"events": []
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
if row.get('Page Type') == '404' or not row.get('URL'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = row.get('URL', '/')
|
|
||||||
views = int(row.get('Views', 0))
|
|
||||||
view_duration = parse_view_duration(row.get('View Duration', '0:00'))
|
|
||||||
|
|
||||||
# Add pageview events
|
|
||||||
for i in range(min(views, 20)): # Limit for API payload size
|
|
||||||
payload["events"].append({
|
|
||||||
"type": "pageview",
|
|
||||||
"url": url,
|
|
||||||
"referrer": "",
|
|
||||||
"duration": view_duration,
|
|
||||||
"timestamp": datetime.now().isoformat() + "Z"
|
|
||||||
})
|
|
||||||
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(payload, f, indent=2)
|
|
||||||
|
|
||||||
print(f"✅ Generated API payload with {len(payload['events'])} events")
|
|
||||||
print(f"📁 Output saved to: {output_file}")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Migrate Independent Analytics to Umami')
|
|
||||||
parser.add_argument('--input', '-i', required=True, help='Input CSV file from Independent Analytics')
|
|
||||||
parser.add_argument('--output', '-o', required=True, help='Output file path')
|
|
||||||
parser.add_argument('--format', '-f', choices=['json', 'sql', 'api'], default='json',
|
|
||||||
help='Output format: json (for API), sql (for DB), api (for API payload)')
|
|
||||||
parser.add_argument('--site-id', '-s', default='your-site-id', help='Umami website ID')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
print(f"🔄 Converting {args.input} to Umami format...")
|
|
||||||
print(f"Format: {args.format}")
|
|
||||||
print(f"Site ID: {args.site_id}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if args.format == 'json':
|
|
||||||
convert_to_umami_format(args.input, args.output, args.site_id)
|
|
||||||
elif args.format == 'sql':
|
|
||||||
generate_sql_import(args.input, args.output, args.site_id)
|
|
||||||
elif args.format == 'api':
|
|
||||||
generate_api_payload(args.input, args.output, args.site_id)
|
|
||||||
|
|
||||||
print("\n✅ Migration completed successfully!")
|
|
||||||
print("\nNext steps:")
|
|
||||||
if args.format == 'json':
|
|
||||||
print("1. Use the JSON file with Umami's import API")
|
|
||||||
elif args.format == 'sql':
|
|
||||||
print("1. Import the SQL file into Umami's database")
|
|
||||||
print("2. Run: psql -U umami -d umami -f output.sql")
|
|
||||||
elif args.format == 'api':
|
|
||||||
print("1. POST the JSON payload to Umami's API endpoint")
|
|
||||||
print("2. Example: curl -X POST -H 'Content-Type: application/json' -d @output.json https://your-umami-instance.com/api/import")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const WP_URL = 'https://klz-cables.com';
|
|
||||||
|
|
||||||
async function fetchAllPosts() {
|
|
||||||
let page = 1;
|
|
||||||
let allPosts: any[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
console.log(`Fetching posts page ${page}...`);
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${WP_URL}/wp-json/wp/v2/posts`, {
|
|
||||||
params: {
|
|
||||||
per_page: 100,
|
|
||||||
page: page,
|
|
||||||
_embed: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const posts = response.data;
|
|
||||||
if (posts.length === 0) break;
|
|
||||||
|
|
||||||
allPosts = allPosts.concat(posts);
|
|
||||||
page++;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response && error.response.status === 400) {
|
|
||||||
// End of pagination
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.error('Error fetching posts:', error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allPosts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMdxContent(post: any, locale: 'en' | 'de') {
|
|
||||||
const frontmatter = {
|
|
||||||
title: post.title.rendered,
|
|
||||||
date: post.date,
|
|
||||||
excerpt: post.excerpt.rendered.replace(/<[^>]*>/g, '').trim(),
|
|
||||||
featuredImage: post._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
|
|
||||||
locale: locale
|
|
||||||
};
|
|
||||||
|
|
||||||
return `---
|
|
||||||
${JSON.stringify(frontmatter, null, 2)}
|
|
||||||
---
|
|
||||||
|
|
||||||
# ${post.title.rendered}
|
|
||||||
|
|
||||||
${post.content.rendered}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const posts = await fetchAllPosts();
|
|
||||||
console.log(`Fetched ${posts.length} posts.`);
|
|
||||||
|
|
||||||
for (const post of posts) {
|
|
||||||
// Determine locale.
|
|
||||||
// If using Polylang, we might check categories or tags, or a specific field if exposed.
|
|
||||||
// Or we can check the link structure if it contains /de/ or /en/ (though API link might be different)
|
|
||||||
// Let's try to guess from the link or content language detection if needed.
|
|
||||||
// For now, let's assume we can filter by category or just save all and manually sort if needed.
|
|
||||||
// Actually, Polylang usually exposes 'lang' in the API if configured, or we might need to fetch by lang.
|
|
||||||
|
|
||||||
// Simple heuristic: check if link contains '/de/'
|
|
||||||
const locale = post.link.includes('/de/') ? 'de' : 'en';
|
|
||||||
|
|
||||||
const mdx = generateMdxContent(post, locale);
|
|
||||||
|
|
||||||
const outDir = path.join(process.cwd(), 'data', 'blog', locale);
|
|
||||||
if (!fs.existsSync(outDir)) {
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = `${post.slug}.mdx`;
|
|
||||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
|
||||||
console.log(`Saved ${filename} (${locale})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(console.error);
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import client, { ensureAuthenticated } from '../lib/directus';
|
|
||||||
import {
|
|
||||||
createCollection,
|
|
||||||
createField,
|
|
||||||
createRelation,
|
|
||||||
uploadFiles,
|
|
||||||
createItem,
|
|
||||||
updateSettings,
|
|
||||||
readFolders,
|
|
||||||
createFolder
|
|
||||||
} from '@directus/sdk';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import matter from 'gray-matter';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
console.log('🚀 CLEAN SLATE MIGRATION 🚀');
|
|
||||||
await ensureAuthenticated();
|
|
||||||
|
|
||||||
// 1. Folders
|
|
||||||
console.log('📂 Creating Folders...');
|
|
||||||
const folders: any = {};
|
|
||||||
const folderNames = ['Products', 'Blog', 'Pages', 'Technical'];
|
|
||||||
for (const name of folderNames) {
|
|
||||||
try {
|
|
||||||
const res = await client.request(createFolder({ name }));
|
|
||||||
folders[name] = res.id;
|
|
||||||
} catch (e) {
|
|
||||||
const existing = await client.request(readFolders({ filter: { name: { _eq: name } } }));
|
|
||||||
folders[name] = existing[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Assets
|
|
||||||
const assetMap: Record<string, string> = {};
|
|
||||||
const uploadDir = async (dir: string, folderId: string) => {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const file of files) {
|
|
||||||
const fullPath = path.join(dir, file.name);
|
|
||||||
if (file.isDirectory()) {
|
|
||||||
await uploadDir(fullPath, folderId);
|
|
||||||
} else {
|
|
||||||
const relPath = '/' + path.relative(path.join(process.cwd(), 'public'), fullPath).split(path.sep).join('/');
|
|
||||||
try {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('folder', folderId);
|
|
||||||
form.append('file', new Blob([fs.readFileSync(fullPath)]), file.name);
|
|
||||||
const res = await client.request(uploadFiles(form));
|
|
||||||
assetMap[relPath] = res.id;
|
|
||||||
console.log(`✅ Asset: ${relPath}`);
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await uploadDir(path.join(process.cwd(), 'public/uploads'), folders.Products);
|
|
||||||
|
|
||||||
// 3. Collections (Minimalist)
|
|
||||||
const collections = [
|
|
||||||
'categories', 'products', 'posts', 'pages', 'globals',
|
|
||||||
'categories_translations', 'products_translations', 'posts_translations', 'pages_translations', 'globals_translations',
|
|
||||||
'categories_link'
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('🏗️ Creating Collections...');
|
|
||||||
for (const name of collections) {
|
|
||||||
try {
|
|
||||||
const isSingleton = name === 'globals';
|
|
||||||
await client.request(createCollection({
|
|
||||||
collection: name,
|
|
||||||
schema: {},
|
|
||||||
meta: { singleton: isSingleton }
|
|
||||||
} as any));
|
|
||||||
|
|
||||||
// Add ID field
|
|
||||||
await client.request(createField(name, {
|
|
||||||
field: 'id',
|
|
||||||
type: 'integer',
|
|
||||||
meta: { hidden: true },
|
|
||||||
schema: { is_primary_key: true, has_auto_increment: name !== 'globals' }
|
|
||||||
}));
|
|
||||||
console.log(`✅ Collection: ${name}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(`ℹ️ Collection ${name} exists or error: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fields & Relations
|
|
||||||
console.log('🔧 Configuring Schema...');
|
|
||||||
const safeAdd = async (col: string, f: any) => { try { await client.request(createField(col, f)); } catch (e) { } };
|
|
||||||
|
|
||||||
// Products
|
|
||||||
await safeAdd('products', { field: 'sku', type: 'string' });
|
|
||||||
await safeAdd('products', { field: 'image', type: 'uuid', meta: { interface: 'file' } });
|
|
||||||
|
|
||||||
// Translations Generic
|
|
||||||
for (const col of ['categories', 'products', 'posts', 'pages', 'globals']) {
|
|
||||||
const transTable = `${col}_translations`;
|
|
||||||
await safeAdd(transTable, { field: `${col}_id`, type: 'integer' });
|
|
||||||
await safeAdd(transTable, { field: 'languages_code', type: 'string' });
|
|
||||||
|
|
||||||
// Link to Parent
|
|
||||||
try {
|
|
||||||
await client.request(createRelation({
|
|
||||||
collection: transTable,
|
|
||||||
field: `${col}_id`,
|
|
||||||
related_collection: col,
|
|
||||||
meta: { one_field: 'translations' }
|
|
||||||
}));
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Specific Fields
|
|
||||||
await safeAdd('products_translations', { field: 'name', type: 'string' });
|
|
||||||
await safeAdd('products_translations', { field: 'slug', type: 'string' });
|
|
||||||
await safeAdd('products_translations', { field: 'description', type: 'text' });
|
|
||||||
await safeAdd('products_translations', { field: 'content', type: 'text', meta: { interface: 'input-rich-text-html' } });
|
|
||||||
await safeAdd('products_translations', { field: 'technical_items', type: 'json' });
|
|
||||||
await safeAdd('products_translations', { field: 'voltage_tables', type: 'json' });
|
|
||||||
|
|
||||||
await safeAdd('categories_translations', { field: 'name', type: 'string' });
|
|
||||||
await safeAdd('posts_translations', { field: 'title', type: 'string' });
|
|
||||||
await safeAdd('posts_translations', { field: 'slug', type: 'string' });
|
|
||||||
await safeAdd('posts_translations', { field: 'content', type: 'text' });
|
|
||||||
|
|
||||||
await safeAdd('globals', { field: 'company_name', type: 'string' });
|
|
||||||
await safeAdd('globals_translations', { field: 'tagline', type: 'string' });
|
|
||||||
|
|
||||||
// M2M Link
|
|
||||||
await safeAdd('categories_link', { field: 'products_id', type: 'integer' });
|
|
||||||
await safeAdd('categories_link', { field: 'categories_id', type: 'integer' });
|
|
||||||
try {
|
|
||||||
await client.request(createRelation({ collection: 'categories_link', field: 'products_id', related_collection: 'products', meta: { one_field: 'categories_link' } }));
|
|
||||||
await client.request(createRelation({ collection: 'categories_link', field: 'categories_id', related_collection: 'categories' }));
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
// 5. Data Import
|
|
||||||
console.log('📥 Importing Data...');
|
|
||||||
const deDir = path.join(process.cwd(), 'data/products/de');
|
|
||||||
const files = fs.readdirSync(deDir).filter(f => f.endsWith('.mdx'));
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const doc = matter(fs.readFileSync(path.join(deDir, file), 'utf8'));
|
|
||||||
const enPath = path.join(process.cwd(), `data/products/en/${file}`);
|
|
||||||
const enDoc = fs.existsSync(enPath) ? matter(fs.readFileSync(enPath, 'utf8')) : doc;
|
|
||||||
|
|
||||||
const clean = (c: string) => c.replace(/<ProductTabs.*?>|<\/ProductTabs>|<ProductTechnicalData.*?\/>/gs, '').trim();
|
|
||||||
const extract = (c: string) => {
|
|
||||||
const m = c.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
|
||||||
try { return m ? JSON.parse(m[1]) : {}; } catch (e) { return {}; }
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.request(createItem('products', {
|
|
||||||
sku: doc.data.sku,
|
|
||||||
image: assetMap[doc.data.images?.[0]] || null,
|
|
||||||
translations: [
|
|
||||||
{ languages_code: 'de-DE', name: doc.data.title, slug: file.replace('.mdx', ''), description: doc.data.description, content: clean(doc.content), technical_items: extract(doc.content).technicalItems, voltage_tables: extract(doc.content).voltageTables },
|
|
||||||
{ languages_code: 'en-US', name: enDoc.data.title, slug: file.replace('.mdx', ''), description: enDoc.data.description, content: clean(enDoc.content), technical_items: extract(enDoc.content).technicalItems, voltage_tables: extract(enDoc.content).voltageTables }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
console.log(`✅ Product: ${doc.data.sku}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`❌ Product ${file}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✨ DONE!');
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(console.error);
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const WP_URL = 'https://klz-cables.com';
|
|
||||||
|
|
||||||
async function fetchAllPages() {
|
|
||||||
let page = 1;
|
|
||||||
let allPages: any[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
console.log(`Fetching pages page ${page}...`);
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${WP_URL}/wp-json/wp/v2/pages`, {
|
|
||||||
params: {
|
|
||||||
per_page: 100,
|
|
||||||
page: page,
|
|
||||||
_embed: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const pages = response.data;
|
|
||||||
if (pages.length === 0) break;
|
|
||||||
|
|
||||||
allPages = allPages.concat(pages);
|
|
||||||
page++;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response && error.response.status === 400) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.error('Error fetching pages:', error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMdxContent(page: any, locale: 'en' | 'de') {
|
|
||||||
const frontmatter = {
|
|
||||||
title: page.title.rendered,
|
|
||||||
excerpt: page.excerpt.rendered.replace(/<[^>]*>/g, '').trim(),
|
|
||||||
featuredImage: page._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
|
|
||||||
locale: locale
|
|
||||||
};
|
|
||||||
|
|
||||||
return `---
|
|
||||||
${JSON.stringify(frontmatter, null, 2)}
|
|
||||||
---
|
|
||||||
|
|
||||||
# ${page.title.rendered}
|
|
||||||
|
|
||||||
${page.content.rendered}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const pages = await fetchAllPages();
|
|
||||||
console.log(`Fetched ${pages.length} pages.`);
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
// Determine locale.
|
|
||||||
const locale = page.link.includes('/de/') ? 'de' : 'en';
|
|
||||||
|
|
||||||
const mdx = generateMdxContent(page, locale);
|
|
||||||
|
|
||||||
const outDir = path.join(process.cwd(), 'data', 'pages', locale);
|
|
||||||
if (!fs.existsSync(outDir)) {
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = `${page.slug}.mdx`;
|
|
||||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
|
||||||
console.log(`Saved ${filename} (${locale})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(console.error);
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { buildDatasheetModel } from './pdf/model/build-datasheet-model';
|
|
||||||
import type { ProductData } from './pdf/model/types';
|
|
||||||
|
|
||||||
const WC_URL = process.env.WOOCOMMERCE_URL;
|
|
||||||
const WC_KEY = process.env.WOOCOMMERCE_CONSUMER_KEY;
|
|
||||||
const WC_SECRET = process.env.WOOCOMMERCE_CONSUMER_SECRET;
|
|
||||||
|
|
||||||
if (!WC_URL || !WC_KEY || !WC_SECRET) {
|
|
||||||
console.error('Missing WooCommerce credentials in .env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllProducts() {
|
|
||||||
let page = 1;
|
|
||||||
let allProducts: any[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
console.log(`Fetching page ${page}...`);
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${WC_URL}/wp-json/wc/v3/products`, {
|
|
||||||
params: {
|
|
||||||
consumer_key: WC_KEY,
|
|
||||||
consumer_secret: WC_SECRET,
|
|
||||||
per_page: 100,
|
|
||||||
page: page
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const products = response.data;
|
|
||||||
if (products.length === 0) break;
|
|
||||||
|
|
||||||
allProducts = allProducts.concat(products);
|
|
||||||
page++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching products:', error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapWcProductToProductData(wcProduct: any, locale: 'en' | 'de'): ProductData {
|
|
||||||
// This mapping needs to be adjusted based on actual WC response structure
|
|
||||||
// and how translations are handled (e.g. if they are separate products or same product with different fields)
|
|
||||||
|
|
||||||
// Assuming standard WC response
|
|
||||||
return {
|
|
||||||
id: wcProduct.id,
|
|
||||||
name: wcProduct.name,
|
|
||||||
shortDescriptionHtml: wcProduct.short_description,
|
|
||||||
descriptionHtml: wcProduct.description,
|
|
||||||
images: wcProduct.images.map((img: any) => img.src),
|
|
||||||
featuredImage: wcProduct.images[0]?.src || null,
|
|
||||||
sku: wcProduct.sku,
|
|
||||||
slug: wcProduct.slug,
|
|
||||||
categories: wcProduct.categories.map((cat: any) => ({ name: cat.name })),
|
|
||||||
attributes: wcProduct.attributes.map((attr: any) => ({
|
|
||||||
name: attr.name,
|
|
||||||
options: attr.options
|
|
||||||
})),
|
|
||||||
locale: locale // This might need to be derived
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMdxContent(product: ProductData, technicalData: any, locale: 'en' | 'de') {
|
|
||||||
const frontmatter = {
|
|
||||||
title: product.name,
|
|
||||||
sku: product.sku,
|
|
||||||
description: product.shortDescriptionHtml.replace(/<[^>]*>/g, ''), // Simple strip tags
|
|
||||||
categories: product.categories.map(c => c.name),
|
|
||||||
images: product.images,
|
|
||||||
locale: locale
|
|
||||||
};
|
|
||||||
|
|
||||||
const technicalDataJson = JSON.stringify(technicalData, null, 2);
|
|
||||||
|
|
||||||
return `---
|
|
||||||
${JSON.stringify(frontmatter, null, 2)}
|
|
||||||
---
|
|
||||||
|
|
||||||
# ${product.name}
|
|
||||||
|
|
||||||
${product.descriptionHtml}
|
|
||||||
|
|
||||||
## Technical Data
|
|
||||||
|
|
||||||
<ProductTechnicalData data={${technicalDataJson}} />
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const products = await fetchAllProducts();
|
|
||||||
console.log(`Fetched ${products.length} products.`);
|
|
||||||
|
|
||||||
for (const product of products) {
|
|
||||||
// Determine locale. WC might return 'lang' property if using plugins like Polylang
|
|
||||||
// Or we might have to infer it.
|
|
||||||
// For now, let's assume we can detect it or default to 'en'.
|
|
||||||
// If the site uses Polylang, usually there is a 'lang' field.
|
|
||||||
|
|
||||||
const locale = product.lang || 'en'; // Default to en if not found
|
|
||||||
|
|
||||||
// We need to handle both en and de.
|
|
||||||
// If the API returns mixed, we process them.
|
|
||||||
// If the API only returns default lang, we might need to fetch translations specifically.
|
|
||||||
|
|
||||||
// Let's try to generate for the detected locale.
|
|
||||||
|
|
||||||
const productData = mapWcProductToProductData(product, locale as 'en' | 'de');
|
|
||||||
|
|
||||||
// Build datasheet model to get technical data
|
|
||||||
// We need to try both locales if we are not sure, or just the one we have.
|
|
||||||
// But buildDatasheetModel takes a locale.
|
|
||||||
|
|
||||||
const model = buildDatasheetModel({ product: productData, locale: locale as 'en' | 'de' });
|
|
||||||
|
|
||||||
if (model.voltageTables.length > 0 || model.technicalItems.length > 0) {
|
|
||||||
console.log(`Generated technical data for ${product.name} (${locale})`);
|
|
||||||
} else {
|
|
||||||
console.warn(`No technical data found for ${product.name} (${locale})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mdx = generateMdxContent(productData, {
|
|
||||||
technicalItems: model.technicalItems,
|
|
||||||
voltageTables: model.voltageTables
|
|
||||||
}, locale as 'en' | 'de');
|
|
||||||
|
|
||||||
const outDir = path.join(process.cwd(), 'data', 'products', locale);
|
|
||||||
if (!fs.existsSync(outDir)) {
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = `${product.slug}.mdx`;
|
|
||||||
fs.writeFileSync(path.join(outDir, filename), mdx);
|
|
||||||
console.log(`Saved ${filename}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(console.error);
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import matter from 'gray-matter';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
|
|
||||||
const STRAPI_TOKEN = process.env.STRAPI_ADMIN_TOKEN; // You'll need to generate this
|
|
||||||
|
|
||||||
async function migrateProducts() {
|
|
||||||
const productsDir = path.join(process.cwd(), 'data/products');
|
|
||||||
const locales = ['de', 'en'];
|
|
||||||
|
|
||||||
for (const locale of locales) {
|
|
||||||
const localeDir = path.join(productsDir, locale);
|
|
||||||
if (!fs.existsSync(localeDir)) continue;
|
|
||||||
|
|
||||||
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.mdx'));
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(localeDir, file);
|
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const { data, content } = matter(fileContent);
|
|
||||||
|
|
||||||
console.log(`Migrating ${data.title} (${locale})...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Check if product exists (by SKU)
|
|
||||||
const existing = await axios.get(`${STRAPI_URL}/api/products?filters[sku][$eq]=${data.sku}&locale=${locale}`, {
|
|
||||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
const productData = {
|
|
||||||
title: data.title,
|
|
||||||
sku: data.sku,
|
|
||||||
description: data.description,
|
|
||||||
application: data.application,
|
|
||||||
content: content,
|
|
||||||
technicalData: data.technicalData || {}, // This might need adjustment based on how it's stored in MDX
|
|
||||||
locale: locale,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing.data.data.length > 0) {
|
|
||||||
// Update
|
|
||||||
const id = existing.data.data[0].id;
|
|
||||||
await axios.put(`${STRAPI_URL}/api/products/${id}`, { data: productData }, {
|
|
||||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
|
||||||
});
|
|
||||||
console.log(`Updated ${data.title}`);
|
|
||||||
} else {
|
|
||||||
// Create
|
|
||||||
await axios.post(`${STRAPI_URL}/api/products`, { data: productData }, {
|
|
||||||
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
|
|
||||||
});
|
|
||||||
console.log(`Created ${data.title}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error migrating ${data.title}:`, error.response?.data || error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: This script requires a running Strapi instance and an admin token.
|
|
||||||
// migrateProducts();
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const jsdom = require('jsdom');
|
|
||||||
const { JSDOM } = jsdom;
|
|
||||||
|
|
||||||
const postsDir = path.join(process.cwd(), 'reference', 'klz-cables-clone', 'posts');
|
|
||||||
const mdxDir = path.join(process.cwd(), 'data', 'blog', 'en');
|
|
||||||
|
|
||||||
const files = fs.readdirSync(postsDir);
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
if (!file.endsWith('.html')) return;
|
|
||||||
|
|
||||||
const slug = file.replace('.html', '');
|
|
||||||
const mdxPath = path.join(mdxDir, `${slug}.mdx`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(mdxPath)) {
|
|
||||||
console.log(`MDX file not found for ${slug}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlContent = fs.readFileSync(path.join(postsDir, file), 'utf8');
|
|
||||||
const dom = new JSDOM(htmlContent);
|
|
||||||
const document = dom.window.document;
|
|
||||||
|
|
||||||
const vlpContainers = document.querySelectorAll('.vlp-link-container');
|
|
||||||
|
|
||||||
if (vlpContainers.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`Processing ${slug} with ${vlpContainers.length} visual links`);
|
|
||||||
|
|
||||||
let mdxContent = fs.readFileSync(mdxPath, 'utf8');
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
vlpContainers.forEach(container => {
|
|
||||||
const link = container.querySelector('a.vlp-link');
|
|
||||||
const titleEl = container.querySelector('.vlp-link-title');
|
|
||||||
const summaryEl = container.querySelector('.vlp-link-summary');
|
|
||||||
const imgEl = container.querySelector('.vlp-link-image img');
|
|
||||||
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
const url = link.getAttribute('href');
|
|
||||||
const title = titleEl ? titleEl.textContent.trim() : '';
|
|
||||||
const summary = summaryEl ? summaryEl.textContent.trim() : '';
|
|
||||||
const image = imgEl ? imgEl.getAttribute('src') : '';
|
|
||||||
|
|
||||||
// Construct the component string
|
|
||||||
const component = `
|
|
||||||
<VisualLinkPreview
|
|
||||||
url="${url}"
|
|
||||||
title="${title.replace(/"/g, '"')}"
|
|
||||||
summary="${summary.replace(/"/g, '"')}"
|
|
||||||
image="${image}"
|
|
||||||
/>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Try to find the link in MDX
|
|
||||||
// It could be [Title](URL) or just URL or <a href="URL">...</a>
|
|
||||||
// We'll try to find the URL and replace the paragraph containing it if it looks like a standalone link
|
|
||||||
// Or just append it if we can't find it easily? No, that's risky.
|
|
||||||
|
|
||||||
// Strategy: Look for the URL.
|
|
||||||
// If found in `[...](url)`, replace the whole markdown link.
|
|
||||||
// If found in `href="url"`, replace the anchor tag.
|
|
||||||
|
|
||||||
const markdownLinkRegex = new RegExp(`\\[.*?\\]\\(${escapeRegExp(url)}\\)`, 'g');
|
|
||||||
const plainUrlRegex = new RegExp(`(?<!\\()${escapeRegExp(url)}(?!\\))`, 'g'); // URL not in parens
|
|
||||||
|
|
||||||
if (markdownLinkRegex.test(mdxContent)) {
|
|
||||||
mdxContent = mdxContent.replace(markdownLinkRegex, component);
|
|
||||||
modified = true;
|
|
||||||
} else if (plainUrlRegex.test(mdxContent)) {
|
|
||||||
// Be careful not to replace inside other attributes
|
|
||||||
// This is a bit loose, but might work for standalone URLs
|
|
||||||
// Better to check if it's a standalone line?
|
|
||||||
// Let's just replace it.
|
|
||||||
mdxContent = mdxContent.replace(plainUrlRegex, component);
|
|
||||||
modified = true;
|
|
||||||
} else {
|
|
||||||
console.log(`Could not find link for ${url} in ${slug}`);
|
|
||||||
// Maybe the URL in MDX is slightly different (e.g. trailing slash)?
|
|
||||||
// Or maybe it's not there at all.
|
|
||||||
// Let's try without trailing slash
|
|
||||||
const urlNoSlash = url.replace(/\/$/, '');
|
|
||||||
const markdownLinkRegex2 = new RegExp(`\\[.*?\\]\\(${escapeRegExp(urlNoSlash)}\\)`, 'g');
|
|
||||||
if (markdownLinkRegex2.test(mdxContent)) {
|
|
||||||
mdxContent = mdxContent.replace(markdownLinkRegex2, component);
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (modified) {
|
|
||||||
fs.writeFileSync(mdxPath, mdxContent);
|
|
||||||
console.log(`Updated ${slug}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function escapeRegExp(string) {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import client, { ensureAuthenticated } from '../lib/directus';
|
|
||||||
import {
|
|
||||||
updateSettings,
|
|
||||||
updateCollection,
|
|
||||||
createItem,
|
|
||||||
updateItem
|
|
||||||
} from '@directus/sdk';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
async function optimize() {
|
|
||||||
await ensureAuthenticated();
|
|
||||||
|
|
||||||
console.log('🎨 Fixing Branding...');
|
|
||||||
await client.request(updateSettings({
|
|
||||||
project_name: 'KLZ Cables',
|
|
||||||
public_note: '<div style="text-align: center;"><h1>Sustainable Energy.</h1><p>Industrial Reliability.</p></div>',
|
|
||||||
custom_css: 'body { font-family: Inter, sans-serif !important; } .public-view .v-card { border-radius: 20px !important; }'
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log('🔧 Fixing List Displays...');
|
|
||||||
const collections = ['products', 'categories', 'posts', 'pages'];
|
|
||||||
for (const collection of collections) {
|
|
||||||
try {
|
|
||||||
await (client as any).request(updateCollection(collection, {
|
|
||||||
meta: { display_template: '{{translations.name || translations.title}}' }
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to update ${collection}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🏛️ Force-Syncing Globals...');
|
|
||||||
const de = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'messages/de.json'), 'utf8'));
|
|
||||||
const en = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'messages/en.json'), 'utf8'));
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
id: 1,
|
|
||||||
company_name: 'KLZ Cables GmbH',
|
|
||||||
email: 'info@klz-cables.com',
|
|
||||||
phone: '+49 711 1234567',
|
|
||||||
address: de.Contact.info.address,
|
|
||||||
opening_hours: `${de.Contact.hours.weekdays}: ${de.Contact.hours.weekdaysTime}`,
|
|
||||||
translations: [
|
|
||||||
{ languages_code: 'en-US', tagline: en.Footer.tagline },
|
|
||||||
{ languages_code: 'de-DE', tagline: de.Footer.tagline }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.request(createItem('globals', payload));
|
|
||||||
} catch (e) {
|
|
||||||
try {
|
|
||||||
await client.request(updateItem('globals', 1, payload));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Globals still failing:', (err as any).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Optimization complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
optimize().catch(console.error);
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user