Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 | |||
| 2ba67af68a | |||
| b0f088a1dc | |||
| f358492a99 | |||
| 32576b5391 | |||
| 1e9cf7d9ab | |||
| f0f840ad5a | |||
| ca352fea3a | |||
| 323886443f | |||
| c5851370bf | |||
| 0186dd2dc9 | |||
| 82156d30f7 | |||
| 3dcde28071 | |||
| c4fca24eca | |||
| 2435b968cc | |||
| b6a1ebd236 | |||
| aa0c9cd9f5 | |||
| a3899f6cdd | |||
| a960a7b139 | |||
| 824ee3cb75 | |||
| 28633f187c | |||
| 51e0d86a6c | |||
| 923ff2071b | |||
| 30eb2e6e0e | |||
| dd830f9077 | |||
| ba16f1d7aa | |||
| 0842c136a6 | |||
| 36b8e64d69 | |||
| 4833af81f4 |
@@ -5,8 +5,6 @@ node_modules
|
|||||||
.gitignore
|
.gitignore
|
||||||
.gitea
|
.gitea
|
||||||
.github
|
.github
|
||||||
public/uploads
|
|
||||||
directus/uploads
|
|
||||||
.turbo
|
.turbo
|
||||||
reference/
|
reference/
|
||||||
.next
|
.next
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-pipeline
|
group: deploy-pipeline
|
||||||
@@ -211,7 +212,7 @@ jobs:
|
|||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||||
|
|
||||||
@@ -357,6 +358,43 @@ jobs:
|
|||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
|
|
||||||
|
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||||
|
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||||
|
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||||
|
echo "⏳ Waiting for database container to be ready..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
|
||||||
|
echo "✅ Database is ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Attempt $i/15..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🔧 Sanitizing payload_migrations table (if exists)..."
|
||||||
|
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
|
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
|
||||||
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
|
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||||
|
DO \\\$\\\$ BEGIN
|
||||||
|
DELETE FROM payload_migrations WHERE batch = -1;
|
||||||
|
INSERT INTO payload_migrations (name, batch)
|
||||||
|
SELECT name, batch FROM (VALUES
|
||||||
|
('20260223_195005_products_collection', 1),
|
||||||
|
('20260223_195151_remove_sku_unique', 2),
|
||||||
|
('20260225_003500_add_pages_collection', 3)
|
||||||
|
) AS v(name, batch)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||||
|
EXCEPTION WHEN undefined_table THEN
|
||||||
|
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||||
|
END \\\$\\\$;
|
||||||
|
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||||
|
|
||||||
|
# Restart app to pick up clean migration state
|
||||||
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
|
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
@@ -364,12 +402,11 @@ jobs:
|
|||||||
run: docker builder prune -f --filter "until=1h"
|
run: docker builder prune -f --filter "until=1h"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 5: Smoke Test (OG Images)
|
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
smoke_test:
|
post_deploy_checks:
|
||||||
name: 🧪 Smoke Test
|
name: 🧪 Post-Deploy Verification
|
||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
continue-on-error: true
|
|
||||||
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -390,20 +427,66 @@ jobs:
|
|||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
id: deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: 🚀 Run OG Image Check
|
|
||||||
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
|
- name: 🚀 OG Image Check
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
run: pnpm run check:og
|
run: pnpm run check:og
|
||||||
|
- name: 🌐 Full Sitemap HTTP Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
- name: 🌐 Locale & Language Switcher Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm run check:locale
|
||||||
|
|
||||||
|
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
||||||
|
- name: 🌐 HTML DOM Validation
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:html
|
||||||
|
- name: 🔒 Security Headers Scan
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:security
|
||||||
|
- name: 🔗 Lychee Deep Link Crawl
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:links
|
||||||
|
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: pnpm check:assets
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 6: Lighthouse (Performance & Accessibility)
|
# JOB 6: Performance & Accessibility (Lighthouse + WCAG)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
lighthouse:
|
performance:
|
||||||
name: ⚡ Lighthouse
|
name: ⚡ Performance & Accessibility
|
||||||
needs: [prepare, deploy, smoke_test]
|
needs: [prepare, post_deploy_checks]
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
if: needs.post_deploy_checks.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -418,7 +501,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
@@ -459,78 +541,14 @@ jobs:
|
|||||||
# Standardize binary paths
|
# Standardize binary paths
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
- name: ⚡ Run Lighthouse CI
|
- name: ⚡ Lighthouse CI
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
CHROME_PATH: /usr/bin/chromium
|
CHROME_PATH: /usr/bin/chromium
|
||||||
PAGESPEED_LIMIT: 8
|
PAGESPEED_LIMIT: 8
|
||||||
run: pnpm run pagespeed:test
|
run: pnpm run pagespeed:test
|
||||||
|
- name: ♿ WCAG Audit
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# JOB 7: WCAG Audit
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
wcag:
|
|
||||||
name: ♿ WCAG
|
|
||||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
|
||||||
continue-on-error: true
|
|
||||||
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: 🔐 Registry Auth
|
|
||||||
run: |
|
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
|
||||||
run: |
|
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y gnupg wget ca-certificates
|
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
OS_ID=$(. /etc/os-release && echo $ID)
|
|
||||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
|
||||||
|
|
||||||
if [ "$OS_ID" = "debian" ]; then
|
|
||||||
echo "🎯 Debian detected - installing native chromium"
|
|
||||||
apt-get install -y chromium
|
|
||||||
else
|
|
||||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
KEY_ID="82BB6851C64F6880"
|
|
||||||
|
|
||||||
# Fetch PPA key
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
|
||||||
|
|
||||||
# Add PPA repository
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
|
|
||||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
|
||||||
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --allow-downgrades chromium
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Standardize binary paths
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
|
||||||
- name: ♿ Run WCAG Audit
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
@@ -539,76 +557,54 @@ jobs:
|
|||||||
run: pnpm run check:wcag
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 9: Quality Assertions
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
quality_assertions:
|
|
||||||
name: 🛡️ Quality Gates
|
|
||||||
needs: [prepare, deploy, smoke_test, lighthouse, wcag]
|
|
||||||
continue-on-error: true
|
|
||||||
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: 🔐 Registry Auth
|
|
||||||
run: |
|
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: 🌐 HTML DOM Validation
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:html
|
|
||||||
- name: 🔒 Security Headers Scan
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:security
|
|
||||||
- name: 🔗 Lychee Deep Link Crawl
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:links
|
|
||||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:assets
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# JOB 10: Notifications
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, smoke_test, lighthouse, wcag, quality_assertions]
|
needs: [prepare, deploy, post_deploy_checks, performance]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: 🔔 Gotify
|
- name: 🔔 Gotify
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
TITLE="klz-cables.com: $STATUS"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
PERF="${{ needs.performance.result }}"
|
||||||
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|
||||||
|
# Gotify priority scale:
|
||||||
|
# 1-3 = low (silent/info)
|
||||||
|
# 4-5 = normal
|
||||||
|
# 6-7 = high (warning)
|
||||||
|
# 8-10 = critical (alarm)
|
||||||
|
if [[ "$DEPLOY" != "success" ]]; then
|
||||||
|
PRIORITY=10
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS_LINE="DEPLOY FAILED"
|
||||||
|
elif [[ "$SMOKE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="⚠️"
|
||||||
|
STATUS_LINE="Smoke tests failed"
|
||||||
|
elif [[ "$PERF" != "success" ]]; then
|
||||||
|
PRIORITY=5
|
||||||
|
EMOJI="📉"
|
||||||
|
STATUS_LINE="Performance degraded"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS_LINE="All checks passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
||||||
|
MESSAGE="$STATUS_LINE
|
||||||
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
|
$URL"
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
-F "message=$MESSAGE" \
|
||||||
-F "priority=$PRIORITY" || true
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||||
<TrackedLink
|
<TrackedLink
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||||
eventProperties={{
|
eventProperties={{
|
||||||
location: 'generic_page_support_cta',
|
location: 'generic_page_support_cta',
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/contact`,
|
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/contact`,
|
de: `${SITE_URL}/de/kontakt`,
|
||||||
en: `${SITE_URL}/en/contact`,
|
en: `${SITE_URL}/en/contact`,
|
||||||
'x-default': `${SITE_URL}/en/contact`,
|
'x-default': `${SITE_URL}/en/contact`,
|
||||||
},
|
},
|
||||||
@@ -34,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
|
|||||||
@@ -55,14 +55,23 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const getLocalizedPath = async (lang: string) => {
|
||||||
|
const parts = await Promise.all([
|
||||||
|
mapFileSlugToTranslated('products', lang),
|
||||||
|
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
|
||||||
|
]);
|
||||||
|
return parts.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
@@ -72,9 +81,9 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -258,7 +267,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
(node.fields?.blockType === 'productTabs' ||
|
(node.fields?.blockType === 'productTabs' ||
|
||||||
node.fields?.blockType === 'productTechnicalData'),
|
node.fields?.blockType === 'productTechnicalData'),
|
||||||
);
|
);
|
||||||
const descriptionChildren = rootChildren.filter(
|
let descriptionChildren = rootChildren.filter(
|
||||||
(node: any) =>
|
(node: any) =>
|
||||||
!(
|
!(
|
||||||
node.type === 'block' &&
|
node.type === 'block' &&
|
||||||
@@ -267,6 +276,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If no standalone description nodes, extract from the productTabs block's embedded content
|
||||||
|
if (descriptionChildren.length === 0) {
|
||||||
|
const tabsBlock = rootChildren.find(
|
||||||
|
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||||
|
);
|
||||||
|
if (tabsBlock?.fields?.content?.root?.children) {
|
||||||
|
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
|
||||||
|
// Filter out MDX parsing artifacts like `}>`
|
||||||
|
if (node.type === 'paragraph' && node.children?.length === 1) {
|
||||||
|
const text = node.children[0]?.text?.trim();
|
||||||
|
return text !== '}>' && text !== '{' && text !== '}';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -402,7 +428,13 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Description Area Next to Sidebar */}
|
{/* Description Area Next to Sidebar */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
||||||
<PayloadRichText data={descriptionContent} />
|
{descriptionChildren.length > 0 ? (
|
||||||
|
<PayloadRichText data={descriptionContent} />
|
||||||
|
) : product.frontmatter.description ? (
|
||||||
|
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
|
||||||
|
{product.frontmatter.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
const contactSlug = await mapFileSlugToTranslated('contact', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
@@ -230,10 +231,10 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${contactSlug}`}
|
||||||
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 px-6 md:px-10 text-sm md:text-xl"
|
||||||
>
|
>
|
||||||
{t('cta.button')}
|
{t('cta.button')}
|
||||||
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const dsnUrl = new URL(realDsn);
|
const dsnUrl = new URL(realDsn);
|
||||||
const projectId = dsnUrl.pathname.replace('/', '');
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
const sentryKey = dsnUrl.username;
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
|
||||||
|
|
||||||
logger.debug('Relaying Sentry envelope', {
|
logger.debug('Relaying Sentry envelope', {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -57,22 +58,18 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
if (!process.env.CI) {
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
logger.error('Sentry/GlitchTip API responded with error', {
|
status: response.status,
|
||||||
status: response.status,
|
error: errorText.slice(0, 100),
|
||||||
error: errorText.slice(0, 100),
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return new NextResponse(errorText, { status: response.status });
|
return new NextResponse(errorText, { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: 'ok' });
|
return NextResponse.json({ status: 'ok' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!process.env.CI) {
|
logger.error('Failed to relay Sentry request', {
|
||||||
logger.error('Failed to relay Sentry request', {
|
error: (error as Error).message,
|
||||||
error: (error as Error).message,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import { getAllPostsMetadata } from '@/lib/blog';
|
|||||||
import { getAllPagesMetadata } from '@/lib/pages';
|
import { getAllPagesMetadata } from '@/lib/pages';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
|
|
||||||
export const revalidate = 3600; // Revalidate every hour
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = process.env.CI
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
? 'http://klz.localhost'
|
|
||||||
: config.baseUrl || 'https://klz-cables.com';
|
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||||
|
|||||||
@@ -173,12 +173,12 @@ export default function Footer() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/contact`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
label: navT('contact'),
|
label: navT('contact'),
|
||||||
href: '/contact',
|
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||||
location: 'footer_company',
|
location: 'footer_company',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function Header() {
|
|||||||
const segmentMap: Record<string, Record<string, string>> = {
|
const segmentMap: Record<string, Record<string, string>> = {
|
||||||
de: {
|
de: {
|
||||||
produkte: 'products',
|
produkte: 'products',
|
||||||
|
kontakt: 'contact',
|
||||||
impressum: 'legal-notice',
|
impressum: 'legal-notice',
|
||||||
datenschutz: 'privacy-policy',
|
datenschutz: 'privacy-policy',
|
||||||
agbs: 'terms',
|
agbs: 'terms',
|
||||||
@@ -103,6 +104,7 @@ export default function Header() {
|
|||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
products: 'produkte',
|
products: 'produkte',
|
||||||
|
contact: 'kontakt',
|
||||||
'legal-notice': 'impressum',
|
'legal-notice': 'impressum',
|
||||||
'privacy-policy': 'datenschutz',
|
'privacy-policy': 'datenschutz',
|
||||||
terms: 'agbs',
|
terms: 'agbs',
|
||||||
@@ -183,24 +185,40 @@ export default function Header() {
|
|||||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||||
>
|
>
|
||||||
<Link
|
{(() => {
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||||
onClick={() => {
|
const isActive =
|
||||||
setIsMobileMenuOpen(false);
|
item.href === '/'
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
label: item.label,
|
: pathname.startsWith(fullHref);
|
||||||
href: item.href,
|
return (
|
||||||
location: 'header_nav',
|
<Link
|
||||||
});
|
href={fullHref}
|
||||||
}}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
onClick={() => {
|
||||||
textColorClass,
|
setIsMobileMenuOpen(false);
|
||||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
)}
|
label: item.label,
|
||||||
>
|
href: item.href,
|
||||||
{item.label}
|
location: 'header_nav',
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
});
|
||||||
</Link>
|
}}
|
||||||
|
className={cn(
|
||||||
|
textColorClass,
|
||||||
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
isActive && 'text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
||||||
|
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -256,7 +274,7 @@ export default function Header() {
|
|||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="md"
|
size="md"
|
||||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||||
@@ -275,7 +293,7 @@ export default function Header() {
|
|||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||||
textColorClass,
|
textColorClass,
|
||||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||||
)}
|
)}
|
||||||
@@ -320,7 +338,7 @@ export default function Header() {
|
|||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
isMobileMenuOpen
|
isMobileMenuOpen
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
@@ -344,6 +362,15 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
aria-current={
|
||||||
|
(
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
|
)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsMobileMenuOpen(false);
|
setIsMobileMenuOpen(false);
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
@@ -352,7 +379,12 @@ export default function Header() {
|
|||||||
location: 'mobile_menu',
|
location: 'mobile_menu',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
className={cn(
|
||||||
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||||
|
(item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -388,7 +420,7 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/contact`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
|
|||||||
@@ -23,10 +23,82 @@ const jsxConverters: JSXConverters = {
|
|||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
||||||
text: ({ node }: any) => {
|
text: ({ node }: any) => {
|
||||||
const text = node.text;
|
const text = node.text;
|
||||||
|
// Handle markdown-style lists embedded in text nodes from MDX migration
|
||||||
|
if (text && text.includes('\n- ')) {
|
||||||
|
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
||||||
|
// If first part doesn't start with "- ", it's a prefix paragraph
|
||||||
|
const startsWithDash = text.trimStart().startsWith('- ');
|
||||||
|
const prefix = startsWithDash ? null : parts.shift();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{prefix && (
|
||||||
|
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
||||||
|
{!prefix.includes('<') ? prefix : undefined}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||||
|
{parts.map((item: string, i: number) => {
|
||||||
|
const cleanItem = item.trim();
|
||||||
|
if (cleanItem.includes('<')) {
|
||||||
|
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
|
||||||
|
}
|
||||||
|
return <li key={i}>{cleanItem}</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
if (text && (text.includes('<') || text.includes('data-start'))) {
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle markdown-style links [text](url) from MDX migration
|
||||||
|
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const remaining = text;
|
||||||
|
let key = 0;
|
||||||
|
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
let match;
|
||||||
|
let lastIndex = 0;
|
||||||
|
while ((match = linkRegex.exec(remaining)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<a
|
||||||
|
key={key++}
|
||||||
|
href={match[2]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
||||||
|
>
|
||||||
|
{match[1]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (lastIndex < remaining.length) {
|
||||||
|
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
||||||
|
}
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
||||||
|
if (text && text.includes('\n')) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lines.map((line: string, i: number) => (
|
||||||
|
<span key={i}>
|
||||||
|
{line}
|
||||||
|
{i < lines.length - 1 && <br />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (node.format === 1) return <strong>{text}</strong>;
|
if (node.format === 1) return <strong>{text}</strong>;
|
||||||
if (node.format === 2) return <em>{text}</em>;
|
if (node.format === 2) return <em>{text}</em>;
|
||||||
return <span>{text}</span>;
|
return <span>{text}</span>;
|
||||||
|
|||||||
@@ -78,7 +78,14 @@
|
|||||||
"Kabel",
|
"Kabel",
|
||||||
"Deutsch",
|
"Deutsch",
|
||||||
"Spannung",
|
"Spannung",
|
||||||
"unbekannt"
|
"unbekannt",
|
||||||
|
"payloadcms",
|
||||||
|
"imgproxy",
|
||||||
|
"Leitungen",
|
||||||
|
"impressum",
|
||||||
|
"datenschutz",
|
||||||
|
"agbs",
|
||||||
|
"kontakt"
|
||||||
],
|
],
|
||||||
"ignorePaths": [
|
"ignorePaths": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|||||||
47
debug-sitemap.ts
Normal file
47
debug-sitemap.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
console.log('DEBUG SCRIPT STARTING...');
|
||||||
|
|
||||||
|
async function debug() {
|
||||||
|
console.log('Importing dependencies...');
|
||||||
|
try {
|
||||||
|
const { getAllProductsMetadata } = await import('./lib/mdx');
|
||||||
|
const { getAllPostsMetadata } = await import('./lib/blog');
|
||||||
|
const { getAllPagesMetadata } = await import('./lib/pages');
|
||||||
|
|
||||||
|
console.log('Dependencies imported.');
|
||||||
|
|
||||||
|
const locales = ['de', 'en'];
|
||||||
|
for (const locale of locales) {
|
||||||
|
console.log(`--- Locale: ${locale} ---`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await getAllProductsMetadata(locale);
|
||||||
|
console.log(`Products (${locale}): ${products.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get products for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const posts = await getAllPostsMetadata(locale);
|
||||||
|
console.log(`Posts (${locale}): ${posts.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get posts for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pages = await getAllPagesMetadata(locale);
|
||||||
|
console.log(`Pages (${locale}): ${pages.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to get pages for ${locale}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Debug failed during setup/imports:', err);
|
||||||
|
}
|
||||||
|
console.log('DEBUG SCRIPT FINISHED.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug().catch((err) => {
|
||||||
|
console.error('Unhandled retransmission error in debug():', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
||||||
|
volumes:
|
||||||
|
- klz_media_data:/app/public/media
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
@@ -26,8 +28,8 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||||
|
|
||||||
# Public Router (Whitelist)
|
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(health|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -59,6 +61,9 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
klz-db:
|
klz-db:
|
||||||
@@ -74,8 +79,6 @@ services:
|
|||||||
- klz_db_data:/var/lib/postgresql/data
|
- klz_db_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
ports:
|
|
||||||
- "54322:5432"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
@@ -86,3 +89,5 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
klz_db_data:
|
klz_db_data:
|
||||||
external: false
|
external: false
|
||||||
|
klz_media_data:
|
||||||
|
external: false
|
||||||
|
|||||||
11
lib/blog.ts
11
lib/blog.ts
@@ -60,13 +60,15 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
slug: { equals: slug },
|
||||||
locale: { equals: locale },
|
locale: { equals: locale },
|
||||||
|
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
draft: process.env.NODE_ENV === 'development',
|
draft: isDev,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,19 +109,22 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
|||||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
// Query only published posts (access checks applied automatically by Payload!)
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
locale: {
|
locale: {
|
||||||
equals: locale,
|
equals: locale,
|
||||||
},
|
},
|
||||||
|
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
sort: '-date',
|
sort: '-date',
|
||||||
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
|
draft: isDev,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[Payload] getAllPosts for ${locale}: Found ${docs.length} docs`);
|
||||||
|
|
||||||
return docs.map((doc) => {
|
return docs.map((doc) => {
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
|
|||||||
87
lib/mdx.ts
87
lib/mdx.ts
@@ -186,29 +186,30 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
select: selectFields,
|
select: selectFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
let products: ProductMdx[] = result.docs
|
console.log(`[Payload] getAllProducts for ${locale}: Found ${result.docs.length} docs`);
|
||||||
.filter((doc) => {
|
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
let products: ProductMdx[] = result.docs.map((doc) => {
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
const resolvedImages = ((doc.images as any[]) || [])
|
||||||
.filter(Boolean);
|
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||||
return resolvedImages.length > 0;
|
.filter(Boolean) as string[];
|
||||||
})
|
|
||||||
.map((doc) => ({
|
const plainCategories = Array.isArray(doc.categories)
|
||||||
slug: doc.slug,
|
? doc.categories.map((c: any) => String(c.category))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: String(doc.slug),
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
title: doc.title,
|
title: String(doc.title),
|
||||||
sku: doc.sku || '',
|
sku: doc.sku ? String(doc.sku) : '',
|
||||||
description: doc.description || '',
|
description: doc.description ? String(doc.description) : '',
|
||||||
categories: Array.isArray(doc.categories)
|
categories: plainCategories,
|
||||||
? doc.categories.map((c: any) => c.category)
|
images: resolvedImages,
|
||||||
: [],
|
locale: String(doc.locale),
|
||||||
images: ((doc.images as any[]) || [])
|
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
|
||||||
.filter(Boolean),
|
|
||||||
locale: doc.locale,
|
|
||||||
},
|
},
|
||||||
content: null,
|
content: null,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Also include English fallbacks for slugs not in this locale
|
// Also include English fallbacks for slugs not in this locale
|
||||||
if (locale !== 'en') {
|
if (locale !== 'en') {
|
||||||
@@ -221,31 +222,35 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
|||||||
select: selectFields,
|
select: selectFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Payload] getAllProducts (en fallbacks) for ${locale}: Found ${enResult.docs.length} docs`,
|
||||||
|
);
|
||||||
|
|
||||||
const fallbacks = enResult.docs
|
const fallbacks = enResult.docs
|
||||||
.filter((doc) => !localeSlugs.has(doc.slug))
|
.filter((doc) => !localeSlugs.has(doc.slug))
|
||||||
.filter((doc) => {
|
.map((doc) => {
|
||||||
const resolvedImages = ((doc.images as any[]) || [])
|
const resolvedImages = ((doc.images as any[]) || [])
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||||
.filter(Boolean);
|
.filter(Boolean) as string[];
|
||||||
return resolvedImages.length > 0;
|
|
||||||
})
|
const plainCategories = Array.isArray(doc.categories)
|
||||||
.map((doc) => ({
|
? doc.categories.map((c: any) => String(c.category))
|
||||||
slug: doc.slug,
|
: [];
|
||||||
frontmatter: {
|
|
||||||
title: doc.title,
|
return {
|
||||||
sku: doc.sku || '',
|
slug: String(doc.slug),
|
||||||
description: doc.description || '',
|
frontmatter: {
|
||||||
categories: Array.isArray(doc.categories)
|
title: String(doc.title),
|
||||||
? doc.categories.map((c: any) => c.category)
|
sku: doc.sku ? String(doc.sku) : '',
|
||||||
: [],
|
description: doc.description ? String(doc.description) : '',
|
||||||
images: ((doc.images as any[]) || [])
|
categories: plainCategories,
|
||||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
images: resolvedImages,
|
||||||
.filter(Boolean),
|
locale: String(doc.locale),
|
||||||
locale: doc.locale,
|
isFallback: true,
|
||||||
isFallback: true,
|
},
|
||||||
},
|
content: null,
|
||||||
content: null,
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
products = [...products, ...fallbacks];
|
products = [...products, ...fallbacks];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default async function middleware(request: NextRequest) {
|
|||||||
pathname.startsWith('/stats') ||
|
pathname.startsWith('/stats') ||
|
||||||
pathname.startsWith('/errors') ||
|
pathname.startsWith('/errors') ||
|
||||||
pathname.startsWith('/health') ||
|
pathname.startsWith('/health') ||
|
||||||
|
pathname.startsWith('/uploads') ||
|
||||||
pathname.includes('/api/og') ||
|
pathname.includes('/api/og') ||
|
||||||
pathname.includes('opengraph-image') ||
|
pathname.includes('opengraph-image') ||
|
||||||
pathname.endsWith('sitemap.xml') ||
|
pathname.endsWith('sitemap.xml') ||
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +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";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -415,16 +415,24 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return {
|
||||||
{
|
beforeFiles: [
|
||||||
source: '/de/produkte',
|
{
|
||||||
destination: '/de/products',
|
source: '/de/produkte',
|
||||||
},
|
destination: '/de/products',
|
||||||
{
|
},
|
||||||
source: '/de/produkte/:path*',
|
{
|
||||||
destination: '/de/products/:path*',
|
source: '/de/produkte/:path*',
|
||||||
},
|
destination: '/de/products/:path*',
|
||||||
];
|
},
|
||||||
|
{
|
||||||
|
source: '/de/kontakt',
|
||||||
|
destination: '/de/contact',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
afterFiles: [],
|
||||||
|
fallback: [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -105,6 +105,8 @@
|
|||||||
"check:a11y": "pa11y-ci",
|
"check:a11y": "pa11y-ci",
|
||||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||||
"check:html": "tsx ./scripts/check-html.ts",
|
"check:html": "tsx ./scripts/check-html.ts",
|
||||||
|
"check:http": "tsx ./scripts/check-http.ts",
|
||||||
|
"check:locale": "tsx ./scripts/check-locale.ts",
|
||||||
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
||||||
"check:security": "tsx ./scripts/check-security.ts",
|
"check:security": "tsx ./scripts/check-security.ts",
|
||||||
"check:links": "bash ./scripts/check-links.sh",
|
"check:links": "bash ./scripts/check-links.sh",
|
||||||
@@ -116,22 +118,17 @@
|
|||||||
"cms:bootstrap": "pnpm run cms:branding:local",
|
"cms:bootstrap": "pnpm 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",
|
||||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
"cms:migrate": "payload migrate",
|
||||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||||
"cms:schema:apply:testing": "./scripts/cms-apply.sh testing",
|
|
||||||
"cms:schema:apply:staging": "./scripts/cms-apply.sh staging",
|
|
||||||
"cms:schema:apply:prod": "./scripts/cms-apply.sh 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:audit": "./scripts/audit-local.sh",
|
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||||
"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')))\"",
|
||||||
"backup:db": "bash ./scripts/backup-db.sh",
|
"backup:db": "bash ./scripts/backup-db.sh",
|
||||||
"restore:db": "bash ./scripts/restore-db.sh",
|
"restore:db": "bash ./scripts/restore-db.sh",
|
||||||
|
"cms:push:testing": "bash ./scripts/cms-sync.sh push testing",
|
||||||
|
"cms:push:prod": "bash ./scripts/cms-sync.sh push prod",
|
||||||
|
"cms:pull:testing": "bash ./scripts/cms-sync.sh pull testing",
|
||||||
|
"cms:pull:prod": "bash ./scripts/cms-sync.sh pull prod",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
|
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
|
||||||
import { BlocksFeature } from '@payloadcms/richtext-lexical';
|
import { BlocksFeature } from '@payloadcms/richtext-lexical';
|
||||||
import { payloadBlocks } from './src/payload/blocks/allBlocks';
|
import { payloadBlocks } from './src/payload/blocks/allBlocks';
|
||||||
|
import { migrations } from './src/migrations';
|
||||||
|
|
||||||
// Only disable sharp cache in production to prevent memory leaks.
|
// Only disable sharp cache in production to prevent memory leaks.
|
||||||
// In dev, the cache avoids 41s+ re-processing per image through VirtioFS.
|
// In dev, the cache avoids 41s+ re-processing per image through VirtioFS.
|
||||||
@@ -20,11 +21,17 @@ import { Posts } from './src/payload/collections/Posts';
|
|||||||
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
||||||
import { Products } from './src/payload/collections/Products';
|
import { Products } from './src/payload/collections/Products';
|
||||||
import { Pages } from './src/payload/collections/Pages';
|
import { Pages } from './src/payload/collections/Pages';
|
||||||
|
import { seedDatabase } from './src/payload/seed';
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
|
onInit: async (payload) => {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
await seedDatabase(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
user: Users.slug,
|
user: Users.slug,
|
||||||
importMap: {
|
importMap: {
|
||||||
@@ -45,6 +52,7 @@ export default buildConfig({
|
|||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
},
|
},
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
|
prodMigrations: migrations,
|
||||||
pool: {
|
pool: {
|
||||||
connectionString:
|
connectionString:
|
||||||
process.env.DATABASE_URI ||
|
process.env.DATABASE_URI ||
|
||||||
|
|||||||
74
scripts/check-http.ts
Normal file
74
scripts/check-http.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(sitemapUrl, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
validateStatus: (status) => status < 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
|
let urls = $('url loc')
|
||||||
|
.map((i, el) => $(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^\/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith('http'))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} target URLs in sitemap.`);
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
console.error('❌ No URLs found in sitemap. Is the site up?');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🔍 Verifying HTTP Status Codes (Limit: None)...`);
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Run fetches sequentially to avoid overwhelming the server during CI
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const u = urls[i];
|
||||||
|
try {
|
||||||
|
const res = await axios.get(u, {
|
||||||
|
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
validateStatus: null, // Don't throw on error status
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status >= 400) {
|
||||||
|
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
console.log(`✅ OK ${res.status} -> ${u}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ NETWORK ERROR: ${err.message} -> ${u}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(`\n❌ HTTP Sitemap Validation Failed. One or more pages returned an error.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✨ Success: All ${urls.length} pages are healthy! (HTTP 200)`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`\n❌ Critical Error during Sitemap Fetch:`, error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
191
scripts/check-locale.ts
Normal file
191
scripts/check-locale.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale & Language Switcher Smoke Test
|
||||||
|
*
|
||||||
|
* For every URL in the sitemap:
|
||||||
|
* 1. Fetches the page HTML
|
||||||
|
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
|
||||||
|
* 3. Verifies each alternate URL uses correctly translated slugs
|
||||||
|
* 4. Verifies each alternate URL returns HTTP 200
|
||||||
|
*/
|
||||||
|
|
||||||
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
|
||||||
|
// Expected slug translations: German key → English value
|
||||||
|
const SLUG_MAP: Record<string, string> = {
|
||||||
|
produkte: 'products',
|
||||||
|
kontakt: 'contact',
|
||||||
|
niederspannungskabel: 'low-voltage-cables',
|
||||||
|
mittelspannungskabel: 'medium-voltage-cables',
|
||||||
|
hochspannungskabel: 'high-voltage-cables',
|
||||||
|
solarkabel: 'solar-cables',
|
||||||
|
impressum: 'legal-notice',
|
||||||
|
datenschutz: 'privacy-policy',
|
||||||
|
agbs: 'terms',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse map: English → German
|
||||||
|
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
|
||||||
|
|
||||||
|
function getExpectedTranslation(
|
||||||
|
sourcePath: string,
|
||||||
|
sourceLocale: string,
|
||||||
|
targetLocale: string,
|
||||||
|
): string {
|
||||||
|
const segments = sourcePath.split('/').filter(Boolean);
|
||||||
|
// First segment is locale
|
||||||
|
segments[0] = targetLocale;
|
||||||
|
|
||||||
|
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
'/' +
|
||||||
|
segments
|
||||||
|
.map((seg, i) => {
|
||||||
|
if (i === 0) return seg; // locale
|
||||||
|
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||||
|
})
|
||||||
|
.join('/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
|
||||||
|
|
||||||
|
// 1. Fetch sitemap
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
const sitemapRes = await axios.get(sitemapUrl, { headers, validateStatus: (s) => s < 400 });
|
||||||
|
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
|
||||||
|
|
||||||
|
let urls = $sitemap('url loc')
|
||||||
|
.map((_i, el) => $sitemap(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith('http'))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
|
||||||
|
|
||||||
|
let totalChecked = 0;
|
||||||
|
let totalPassed = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
const locale = path.split('/')[1];
|
||||||
|
if (!locale || !['de', 'en'].includes(locale)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { headers, validateStatus: null });
|
||||||
|
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
|
||||||
|
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
// Extract hreflang alternate links
|
||||||
|
const alternates: { hreflang: string; href: string }[] = [];
|
||||||
|
$('link[rel="alternate"][hreflang]').each((_i, el) => {
|
||||||
|
const hreflang = $(el).attr('hreflang') || '';
|
||||||
|
let href = $(el).attr('href') || '';
|
||||||
|
if (href && hreflang && hreflang !== 'x-default') {
|
||||||
|
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ''));
|
||||||
|
alternates.push({ hreflang, href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alternates.length === 0) {
|
||||||
|
// Some pages may not have alternates, that's OK
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalChecked++;
|
||||||
|
|
||||||
|
// Validate each alternate
|
||||||
|
let pageOk = true;
|
||||||
|
|
||||||
|
for (const alt of alternates) {
|
||||||
|
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||||
|
|
||||||
|
// 1. Check slug translation is correct
|
||||||
|
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||||
|
const actualPath = new URL(alt.href).pathname;
|
||||||
|
|
||||||
|
if (actualPath !== expectedPath) {
|
||||||
|
console.error(
|
||||||
|
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
|
||||||
|
);
|
||||||
|
failures.push(
|
||||||
|
`Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
|
||||||
|
);
|
||||||
|
pageOk = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check alternate URL returns 200
|
||||||
|
try {
|
||||||
|
const altRes = await axios.get(alt.href, {
|
||||||
|
headers,
|
||||||
|
validateStatus: null,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
if (altRes.status >= 400) {
|
||||||
|
console.error(`❌ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`);
|
||||||
|
failures.push(`Broken alternate: ${path} → ${alt.href} (${altRes.status})`);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`);
|
||||||
|
failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOk) {
|
||||||
|
console.log(
|
||||||
|
`✅ ${path} — alternates OK (${alternates
|
||||||
|
.map((a) => a.hreflang)
|
||||||
|
.filter((h) => h !== locale)
|
||||||
|
.join(', ')})`,
|
||||||
|
);
|
||||||
|
totalPassed++;
|
||||||
|
} else {
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${'─'.repeat(60)}`);
|
||||||
|
console.log(`📊 Locale Smoke Test Results:`);
|
||||||
|
console.log(` Pages checked: ${totalChecked}`);
|
||||||
|
console.log(` Passed: ${totalPassed}`);
|
||||||
|
console.log(` Failed: ${totalFailed}`);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`\n❌ Failures:`);
|
||||||
|
failures.forEach((f) => console.log(` • ${f}`));
|
||||||
|
console.log(`\n❌ Locale Smoke Test FAILED.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✨ All locale alternates are correctly translated and reachable!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n❌ Critical error:`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const routes = [
|
|||||||
'/de/opengraph-image',
|
'/de/opengraph-image',
|
||||||
'/en/opengraph-image',
|
'/en/opengraph-image',
|
||||||
'/de/blog/opengraph-image',
|
'/de/blog/opengraph-image',
|
||||||
'/de/api/og/product?slug=nay2y',
|
'/de/api/og/product?slug=low-voltage-cables',
|
||||||
'/en/api/og/product?slug=medium-voltage-cables',
|
'/en/api/og/product?slug=medium-voltage-cables',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
250
scripts/cms-sync.sh
Executable file
250
scripts/cms-sync.sh
Executable file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# CMS Data Sync Tool
|
||||||
|
# Safely syncs Payload CMS data (DB + media) between environments.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cms:push:testing – Push local → testing
|
||||||
|
# cms:push:prod – Push local → production
|
||||||
|
# cms:pull:testing – Pull testing → local
|
||||||
|
# cms:pull:prod – Pull production → local
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
DIRECTION="${1:-}" # push | pull
|
||||||
|
TARGET="${2:-}" # testing | prod
|
||||||
|
SSH_HOST="root@alpha.mintel.me"
|
||||||
|
LOCAL_DB_USER="${PAYLOAD_DB_USER:-payload}"
|
||||||
|
LOCAL_DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
||||||
|
LOCAL_DB_CONTAINER="klz-2026-klz-db-1"
|
||||||
|
LOCAL_MEDIA_DIR="./public/media"
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# Remote credentials (resolved per-target from server env files)
|
||||||
|
REMOTE_DB_USER=""
|
||||||
|
REMOTE_DB_NAME=""
|
||||||
|
|
||||||
|
# Migration names to insert after restore (keeps Payload from prompting)
|
||||||
|
MIGRATIONS=(
|
||||||
|
"20260223_195005_products_collection:1"
|
||||||
|
"20260223_195151_remove_sku_unique:2"
|
||||||
|
"20260225_003500_add_pages_collection:3"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Resolve target environment ─────────────────────────────────────────────
|
||||||
|
resolve_target() {
|
||||||
|
case "$TARGET" in
|
||||||
|
testing)
|
||||||
|
REMOTE_PROJECT="klz-testing"
|
||||||
|
REMOTE_DB_CONTAINER="klz-testing-klz-db-1"
|
||||||
|
REMOTE_APP_CONTAINER="klz-testing-klz-app-1"
|
||||||
|
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-testing_klz_media_data/_data"
|
||||||
|
REMOTE_SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||||
|
;;
|
||||||
|
prod|production)
|
||||||
|
REMOTE_PROJECT="klz-cablescom"
|
||||||
|
REMOTE_DB_CONTAINER="klz-cablescom-klz-db-1"
|
||||||
|
REMOTE_APP_CONTAINER="klz-cablescom-klz-app-1"
|
||||||
|
REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data"
|
||||||
|
REMOTE_SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Unknown target: $TARGET"
|
||||||
|
echo " Valid targets: testing, prod"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Auto-detect remote DB credentials from the env file on the server
|
||||||
|
echo "🔍 Detecting $TARGET database credentials..."
|
||||||
|
REMOTE_DB_USER=$(ssh "$SSH_HOST" "grep -h '^PAYLOAD_DB_USER=' $REMOTE_SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "")
|
||||||
|
REMOTE_DB_NAME=$(ssh "$SSH_HOST" "grep -h '^PAYLOAD_DB_NAME=' $REMOTE_SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "")
|
||||||
|
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||||
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
|
echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ensure local DB is running ─────────────────────────────────────────────
|
||||||
|
ensure_local_db() {
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "$LOCAL_DB_CONTAINER"; then
|
||||||
|
echo "⏳ Local DB container not running. Starting..."
|
||||||
|
docker compose up -d klz-db
|
||||||
|
echo "⏳ Waiting for local DB to be ready..."
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if docker exec "$LOCAL_DB_CONTAINER" pg_isready -U "$LOCAL_DB_USER" -q 2>/dev/null; then
|
||||||
|
echo "✅ Local DB is ready."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "❌ Local DB failed to start."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Sanitize migrations table ──────────────────────────────────────────────
|
||||||
|
sanitize_migrations() {
|
||||||
|
local container="$1"
|
||||||
|
local db_user="$2"
|
||||||
|
local db_name="$3"
|
||||||
|
local is_remote="$4" # "true" or "false"
|
||||||
|
|
||||||
|
echo "🔧 Sanitizing payload_migrations table..."
|
||||||
|
local SQL="DELETE FROM payload_migrations WHERE batch = -1;"
|
||||||
|
for entry in "${MIGRATIONS[@]}"; do
|
||||||
|
local name="${entry%%:*}"
|
||||||
|
local batch="${entry##*:}"
|
||||||
|
SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$is_remote" = "true" ]; then
|
||||||
|
ssh "$SSH_HOST" "docker exec $container psql -U $db_user -d $db_name -c \"$SQL\""
|
||||||
|
else
|
||||||
|
docker exec "$container" psql -U "$db_user" -d "$db_name" -c "$SQL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Safety: Create backup before overwriting ───────────────────────────────
|
||||||
|
backup_local_db() {
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
local file="$BACKUP_DIR/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
||||||
|
echo "📦 Creating safety backup of local DB → $file"
|
||||||
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file"
|
||||||
|
echo "✅ Backup: $file ($(du -h "$file" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_remote_db() {
|
||||||
|
local file="/tmp/payload_pre_sync_${TIMESTAMP}.sql.gz"
|
||||||
|
echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file"
|
||||||
|
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file"
|
||||||
|
echo "✅ Remote backup: $file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
||||||
|
do_push() {
|
||||||
|
echo ""
|
||||||
|
echo "┌──────────────────────────────────────────────────┐"
|
||||||
|
echo "│ 📤 PUSH: local → $TARGET "
|
||||||
|
echo "│ This will OVERWRITE the $TARGET database! "
|
||||||
|
echo "│ A safety backup will be created first. "
|
||||||
|
echo "└──────────────────────────────────────────────────┘"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure? (y/N) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
|
# 0. Ensure local DB is running
|
||||||
|
ensure_local_db
|
||||||
|
|
||||||
|
# 1. Safety backup of remote
|
||||||
|
backup_remote_db
|
||||||
|
|
||||||
|
# 2. Dump local DB
|
||||||
|
echo "📤 Dumping local database..."
|
||||||
|
local dump="/tmp/payload_push_${TIMESTAMP}.sql.gz"
|
||||||
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$dump"
|
||||||
|
|
||||||
|
# 3. Transfer and restore
|
||||||
|
echo "📤 Transferring to $SSH_HOST..."
|
||||||
|
scp "$dump" "$SSH_HOST:/tmp/payload_push.sql.gz"
|
||||||
|
|
||||||
|
echo "🔄 Restoring database on $TARGET..."
|
||||||
|
ssh "$SSH_HOST" "gunzip -c /tmp/payload_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet"
|
||||||
|
|
||||||
|
# 4. Sanitize migrations
|
||||||
|
sanitize_migrations "$REMOTE_DB_CONTAINER" "$REMOTE_DB_USER" "$REMOTE_DB_NAME" "true"
|
||||||
|
|
||||||
|
# 5. Sync media
|
||||||
|
echo "🖼️ Syncing media files..."
|
||||||
|
rsync -az --delete --progress "$LOCAL_MEDIA_DIR/" "$SSH_HOST:$REMOTE_MEDIA_VOLUME/"
|
||||||
|
|
||||||
|
# Fix ownership: rsync preserves local UID, but container runs as nextjs (1001)
|
||||||
|
echo "🔑 Fixing media file permissions..."
|
||||||
|
ssh "$SSH_HOST" "docker exec -u 0 $REMOTE_APP_CONTAINER chown -R 1001:65533 /app/public/media/ 2>/dev/null || true"
|
||||||
|
|
||||||
|
# 6. Restart app
|
||||||
|
echo "🔄 Restarting $TARGET app container..."
|
||||||
|
ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f "$dump"
|
||||||
|
ssh "$SSH_HOST" "rm -f /tmp/payload_push.sql.gz"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Push to $TARGET complete!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PULL: remote → local ──────────────────────────────────────────────────
|
||||||
|
do_pull() {
|
||||||
|
echo ""
|
||||||
|
echo "┌──────────────────────────────────────────────────┐"
|
||||||
|
echo "│ 📥 PULL: $TARGET → local "
|
||||||
|
echo "│ This will OVERWRITE your local database! "
|
||||||
|
echo "│ A safety backup will be created first. "
|
||||||
|
echo "└──────────────────────────────────────────────────┘"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure? (y/N) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||||
|
|
||||||
|
# 0. Ensure local DB is running
|
||||||
|
ensure_local_db
|
||||||
|
|
||||||
|
# 1. Safety backup of local
|
||||||
|
backup_local_db
|
||||||
|
|
||||||
|
# 2. Dump remote DB
|
||||||
|
echo "📥 Dumping $TARGET database..."
|
||||||
|
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > /tmp/payload_pull.sql.gz"
|
||||||
|
|
||||||
|
# 3. Transfer and restore
|
||||||
|
echo "📥 Downloading from $SSH_HOST..."
|
||||||
|
scp "$SSH_HOST:/tmp/payload_pull.sql.gz" "/tmp/payload_pull.sql.gz"
|
||||||
|
|
||||||
|
echo "🔄 Restoring database locally..."
|
||||||
|
gunzip -c "/tmp/payload_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet
|
||||||
|
|
||||||
|
# 4. Sync media
|
||||||
|
echo "🖼️ Syncing media files..."
|
||||||
|
mkdir -p "$LOCAL_MEDIA_DIR"
|
||||||
|
rsync -az --delete --info=progress2 "$SSH_HOST:$REMOTE_MEDIA_VOLUME/" "$LOCAL_MEDIA_DIR/"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f "/tmp/payload_pull.sql.gz"
|
||||||
|
ssh "$SSH_HOST" "rm -f /tmp/payload_pull.sql.gz"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Pull from $TARGET complete! Restart dev server to see changes."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then
|
||||||
|
echo "📦 CMS Data Sync Tool"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " pnpm cms:push:testing Push local DB + media → testing"
|
||||||
|
echo " pnpm cms:push:prod Push local DB + media → production"
|
||||||
|
echo " pnpm cms:pull:testing Pull testing DB + media → local"
|
||||||
|
echo " pnpm cms:pull:prod Pull production DB + media → local"
|
||||||
|
echo ""
|
||||||
|
echo "Safety: A backup is always created before overwriting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolve_target
|
||||||
|
|
||||||
|
case "$DIRECTION" in
|
||||||
|
push) do_push ;;
|
||||||
|
pull) do_pull ;;
|
||||||
|
*)
|
||||||
|
echo "❌ Unknown direction: $DIRECTION (use 'push' or 'pull')"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
5
src/migrations/20260225_003500_add_pages_collection.json
Normal file
5
src/migrations/20260225_003500_add_pages_collection.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "20260225_003500_add_pages_collection",
|
||||||
|
"name": "20260225_003500_add_pages_collection",
|
||||||
|
"batch": 3
|
||||||
|
}
|
||||||
48
src/migrations/20260225_003500_add_pages_collection.ts
Normal file
48
src/migrations/20260225_003500_add_pages_collection.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "public"."enum_pages_locale" AS ENUM('en', 'de');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "pages" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"slug" varchar NOT NULL,
|
||||||
|
"locale" "enum_pages_locale" NOT NULL,
|
||||||
|
"excerpt" varchar,
|
||||||
|
"featured_image_id" integer,
|
||||||
|
"content" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "pages" ADD CONSTRAINT "pages_featured_image_id_media_id_fk"
|
||||||
|
FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id")
|
||||||
|
ON DELETE set null ON UPDATE no action;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "pages_featured_image_idx" ON "pages" USING btree ("featured_image_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
|
||||||
|
CREATE INDEX IF NOT EXISTS "pages_created_at_idx" ON "pages" USING btree ("created_at");
|
||||||
|
|
||||||
|
-- Add pages_id to payload_locked_documents_rels if not already present
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "pages_id" integer;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk"
|
||||||
|
FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id")
|
||||||
|
ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_pages_id_idx"
|
||||||
|
ON "payload_locked_documents_rels" USING btree ("pages_id");
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_pages_fk";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "pages_id";
|
||||||
|
DROP TABLE IF EXISTS "pages" CASCADE;
|
||||||
|
DROP TYPE IF EXISTS "public"."enum_pages_locale";
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as migration_20260223_195005_products_collection from './20260223_195005_products_collection';
|
import * as migration_20260223_195005_products_collection from './20260223_195005_products_collection';
|
||||||
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
||||||
|
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -12,4 +13,9 @@ export const migrations = [
|
|||||||
down: migration_20260223_195151_remove_sku_unique.down,
|
down: migration_20260223_195151_remove_sku_unique.down,
|
||||||
name: '20260223_195151_remove_sku_unique',
|
name: '20260223_195151_remove_sku_unique',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260225_003500_add_pages_collection.up,
|
||||||
|
down: migration_20260225_003500_add_pages_collection.down,
|
||||||
|
name: '20260225_003500_add_pages_collection',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
44
src/payload/seed.ts
Normal file
44
src/payload/seed.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Payload } from 'payload';
|
||||||
|
|
||||||
|
export async function seedDatabase(payload: Payload) {
|
||||||
|
// Check if any users exist
|
||||||
|
const { totalDocs: totalUsers } = await payload.find({
|
||||||
|
collection: 'users',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalUsers === 0) {
|
||||||
|
payload.logger.info('👤 No users found. Creating default admin user...');
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'admin@mintel.me',
|
||||||
|
password: 'klz-admin-setup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
payload.logger.info('✅ Default admin user created successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any products exist
|
||||||
|
const { totalDocs: totalProducts } = await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalProducts === 0) {
|
||||||
|
payload.logger.info('📦 No products found. Creating smoke test product (NAY2Y)...');
|
||||||
|
await payload.create({
|
||||||
|
collection: 'products',
|
||||||
|
data: {
|
||||||
|
title: 'NAY2Y Smoke Test',
|
||||||
|
sku: 'SMOKE-TEST-001',
|
||||||
|
slug: 'nay2y',
|
||||||
|
description: 'A dummy product for CI/CD smoke testing and OG image verification.',
|
||||||
|
locale: 'de',
|
||||||
|
categories: [{ category: 'Power Cables' }],
|
||||||
|
_status: 'published',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
payload.logger.info('✅ Smoke test product created successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user