Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fc6fcbbe | ||
|
|
4e0d8a0f3a | ||
|
|
11723bf184 | ||
|
|
1756b630ef | ||
|
|
daabf8bb63 | ||
|
|
e524c9faf6 | ||
|
|
15279c8be1 | ||
|
|
583a3797f3 | ||
|
|
655f33091f | ||
|
|
34bb91c04b | ||
|
|
449b7bc8aa | ||
|
|
b033142599 | ||
|
|
02be8e59b2 | ||
|
|
d2418b5720 | ||
|
|
501f9659a1 | ||
|
|
e9ceae3989 | ||
|
|
6a748a3ac8 | ||
|
|
d69e0eebe6 | ||
|
|
1577bfd2ec | ||
|
|
6440d893f0 | ||
|
|
d8e3c7d9a3 | ||
|
|
aa14f39dba | ||
|
|
1cfc0523f3 | ||
|
|
3ff20fd2c9 | ||
|
|
549ee34490 | ||
|
|
8a8e30400c | ||
|
|
4faed38f47 | ||
|
|
1e0886144f | ||
|
|
c933d9b886 | ||
|
|
5c56d8babf | ||
|
|
c4c6fb3b07 | ||
|
|
ff685b9933 | ||
|
|
980258af5c | ||
|
|
57b6963efe | ||
|
|
1a136540d0 | ||
|
|
92bc88dfbd | ||
|
|
ec3f2cf8c9 | ||
|
|
fb3ec6e10a | ||
|
|
3a61d01384 | ||
|
|
acf642d7e6 | ||
|
|
17ebde407e | ||
|
|
d5da2a91c8 | ||
|
|
ebe664f984 | ||
|
|
9c7324ee92 | ||
|
|
0c8d9ea669 | ||
|
|
1bb0efc85b | ||
|
|
56cd1fb1ba | ||
|
|
437dd35c9c | ||
|
|
4adf547265 | ||
|
|
0cb96dfbac | ||
|
|
ec227d614f | ||
|
|
cb07b739b8 | ||
|
|
55e9531698 | ||
|
|
089ce13c59 | ||
|
|
a2cf9791ae | ||
|
|
aa4e3aab4f | ||
|
|
ce719a1d70 | ||
|
|
bd2f92125b | ||
|
|
eebe7972e0 | ||
|
|
a9c7fa7c5e | ||
|
|
85e7ff71d5 | ||
|
|
2acb0c1608 | ||
|
|
082733c4f4 | ||
|
|
af67ae7994 | ||
|
|
1fd247e358 | ||
|
|
44401cf546 | ||
|
|
7f106b1fa7 | ||
|
|
08425a3a42 | ||
|
|
62f1e9a89c | ||
|
|
a5718c5013 | ||
|
|
82bb7240d5 | ||
|
|
9e7f6ec76f | ||
|
|
b3057d8be0 | ||
|
|
3b45a967f7 | ||
|
|
cadb104917 | ||
|
|
0be885428d | ||
|
|
009f12a3bf | ||
|
|
8e2a06d6f2 | ||
|
|
4f2bf3fa51 | ||
|
|
064ebf45e3 | ||
|
|
e6dfeaffef | ||
|
|
7cdfe5d7f8 | ||
|
|
83f4b8eea8 | ||
|
|
97e76c7cac | ||
|
|
6caa850045 | ||
|
|
04ce0ecedd | ||
|
|
083859d52d | ||
|
|
a13074902b | ||
|
|
4280f11772 | ||
|
|
3049c1b6e7 | ||
|
|
647f9a5f19 | ||
|
|
a2872be02e | ||
|
|
9c3c7bd34b | ||
|
|
45602db7ff | ||
|
|
89405e6e18 | ||
|
|
57d54231eb | ||
|
|
5c4225d0a9 | ||
|
|
e1101f2e60 | ||
|
|
0be6076512 | ||
|
|
62400943c2 | ||
|
|
4c60029e21 | ||
|
|
b3c5b911d9 | ||
|
|
89f00c79a1 | ||
|
|
98ac3dbd10 | ||
|
|
0db4c819ff | ||
|
|
08a3b0be7b | ||
|
|
a953820241 | ||
|
|
fa02ac597f | ||
|
|
925765233e |
@@ -7,6 +7,7 @@ SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
@@ -25,3 +26,13 @@ MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
|||||||
PAYLOAD_DB_NAME=payload
|
PAYLOAD_DB_NAME=payload
|
||||||
PAYLOAD_DB_USER=payload
|
PAYLOAD_DB_USER=payload
|
||||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Hetzner S3 Object Storage
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
|
S3_BUCKET=mintel
|
||||||
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
@@ -27,14 +27,13 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔐 Configure Private Registry
|
- name: 🔐 Configure Private Registry
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install --no-frozen-lockfile
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
env:
|
env:
|
||||||
|
|||||||
+86
-139
@@ -86,21 +86,24 @@ jobs:
|
|||||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
|
||||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
else
|
else
|
||||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
echo "image_tag=$IMAGE_TAG"
|
echo "image_tag=$IMAGE_TAG"
|
||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$PRIMARY_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "traefik_rule=$TRAEFIK_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||||
echo "next_public_url=https://$PRIMARY_HOST"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
echo "project_name=klz-cablescom"
|
echo "project_name=klz-cablescom"
|
||||||
@@ -169,18 +172,20 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
- name: 🔒 Security Audit
|
- name: 🔒 Security Audit
|
||||||
run: pnpm audit --audit-level high
|
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
if: github.event.inputs.skip_checks != 'true'
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
env:
|
env:
|
||||||
TURBO_TELEMETRY_DISABLED: "1"
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
run: npx turbo run lint check:spell typecheck test --cache-dir=".turbo"
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
@@ -198,23 +203,23 @@ jobs:
|
|||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: echo "${{ secrets.NPM_TOKEN }}" | docker login git.infra.mintel.me -u "${{ github.repository_owner }}" --password-stdin
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
platforms: linux/arm64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
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.NPM_TOKEN }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
tags: git.infra.mintel.me/mmintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -231,6 +236,7 @@ jobs:
|
|||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
|
|
||||||
# Secrets mapping (Payload CMS)
|
# Secrets mapping (Payload CMS)
|
||||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
@@ -282,7 +288,7 @@ jobs:
|
|||||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
# Gatekeeper Origin
|
# Gatekeeper Origin
|
||||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "# Generated by CI - $TARGET"
|
echo "# Generated by CI - $TARGET"
|
||||||
@@ -318,6 +324,7 @@ jobs:
|
|||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
|
||||||
echo "TRAEFIK_ENTRYPOINT=websecure"
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
echo "TRAEFIK_TLS=true"
|
echo "TRAEFIK_TLS=true"
|
||||||
echo "TRAEFIK_CERT_RESOLVER=le"
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
@@ -356,7 +363,7 @@ jobs:
|
|||||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.NPM_TOKEN }}' | docker login git.infra.mintel.me -u '${{ github.repository_owner }}' --password-stdin"
|
||||||
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"
|
||||||
|
|
||||||
@@ -378,20 +385,29 @@ jobs:
|
|||||||
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_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_USER="${REMOTE_DB_USER:-payload}"
|
||||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||||
|
|
||||||
|
# Auto-detect migrations from src/migrations/*.ts
|
||||||
|
BATCH=1
|
||||||
|
VALUES=""
|
||||||
|
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||||
|
NAME=$(basename "$f" .ts)
|
||||||
|
[ -n "$VALUES" ] && VALUES="$VALUES,"
|
||||||
|
VALUES="$VALUES ('$NAME', $BATCH)"
|
||||||
|
((BATCH++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$VALUES" ]; then
|
||||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||||
DO \\\$\\\$ BEGIN
|
DO \\\$\\\$ BEGIN
|
||||||
DELETE FROM payload_migrations WHERE batch = -1;
|
DELETE FROM payload_migrations WHERE batch = -1;
|
||||||
INSERT INTO payload_migrations (name, batch)
|
INSERT INTO payload_migrations (name, batch)
|
||||||
SELECT name, batch FROM (VALUES
|
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
||||||
('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);
|
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||||
EXCEPTION WHEN undefined_table THEN
|
EXCEPTION WHEN undefined_table THEN
|
||||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||||
END \\\$\\\$;
|
END \\\$\\\$;
|
||||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart app to pick up clean migration state
|
# Restart app to pick up clean migration state
|
||||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
@@ -426,12 +442,28 @@ jobs:
|
|||||||
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://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
id: deps
|
id: deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
- name: 🔍 Install Chromium (for Asset Scan)
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Cache APT Packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||||
|
|
||||||
|
- name: 💾 Cache Chromium
|
||||||
|
id: cache-chromium
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /usr/bin/chromium
|
||||||
|
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
apt-get update
|
apt-get update
|
||||||
@@ -453,139 +485,54 @@ jobs:
|
|||||||
[ -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
|
||||||
|
|
||||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
|
- name: 🏥 CMS Deep Health Check
|
||||||
|
env:
|
||||||
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: |
|
||||||
|
echo "Waiting 10s for app to fully start..."
|
||||||
|
sleep 10
|
||||||
|
echo "Checking basic health..."
|
||||||
|
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
echo "Checking CMS DB connectivity..."
|
||||||
|
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||||
|
echo "❌ CMS health check failed!"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
echo ""
|
||||||
|
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||||
|
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ CMS health: $RESPONSE"
|
||||||
- name: 🚀 OG Image Check
|
- name: 🚀 OG Image Check
|
||||||
if: always() && steps.deps.outcome == 'success'
|
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
|
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
with:
|
||||||
|
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
run: pnpm run check:http
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
- name: 🌐 Locale & Language Switcher Validation
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
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: 📝 E2E Form Submission Test
|
||||||
- name: 🌐 HTML DOM Validation
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
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:
|
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' }}
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
CHROME_PATH: /usr/bin/chromium
|
run: pnpm run check:forms
|
||||||
run: pnpm check:assets
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# JOB 6: Performance & Accessibility (Lighthouse + WCAG)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
performance:
|
|
||||||
name: ⚡ Performance & Accessibility
|
|
||||||
needs: [prepare, post_deploy_checks]
|
|
||||||
continue-on-error: true
|
|
||||||
if: needs.post_deploy_checks.result == 'success' && 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: ⚡ Lighthouse CI
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run pagespeed:test
|
|
||||||
- name: ♿ WCAG Audit
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run check:wcag
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 7: Notifications
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, post_deploy_checks, performance]
|
needs: [prepare, deploy, post_deploy_checks]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -596,7 +543,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
DEPLOY="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
PERF="${{ needs.performance.result }}"
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
TARGET="${{ needs.prepare.outputs.target }}"
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
name: Nightly QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
static:
|
||||||
|
name: 🔍 Static Analysis
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🌐 HTML Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:html
|
||||||
|
- name: 🖼️ Broken Assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
ASSET_CHECK_LIMIT: 10
|
||||||
|
run: pnpm run check:assets
|
||||||
|
- name: 🔒 HTTP Headers
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 2. Accessibility (WCAG)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
a11y:
|
||||||
|
name: ♿ Accessibility
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: ♿ WCAG Scan
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 3. Performance (Lighthouse)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: 🎭 Lighthouse
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🎭 Desktop
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||||
|
- name: 📱 Mobile
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 4. Link Check & Dependency Audit
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
links:
|
||||||
|
name: 🔗 Links & Deps
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Depcheck
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||||
|
- name: 🔗 Lychee Link Check
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||||
|
fail: true
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 5. Notification
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
notify:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [static, a11y, lighthouse, links]
|
||||||
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
STATIC="${{ needs.static.result }}"
|
||||||
|
A11Y="${{ needs.a11y.result }}"
|
||||||
|
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||||
|
LINKS="${{ needs.links.result }}"
|
||||||
|
|
||||||
|
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS="Failed"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS="Passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||||
|
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||||
|
${{ env.TARGET_URL }}"
|
||||||
|
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=$MESSAGE" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
+5
-1
@@ -17,6 +17,10 @@
|
|||||||
"valid-id": "off",
|
"valid-id": "off",
|
||||||
"element-required-attributes": "off",
|
"element-required-attributes": "off",
|
||||||
"attribute-empty-style": "off",
|
"attribute-empty-style": "off",
|
||||||
"element-permitted-content": "off"
|
"element-permitted-content": "off",
|
||||||
|
"element-required-content": "off",
|
||||||
|
"element-permitted-parent": "off",
|
||||||
|
"no-implicit-close": "off",
|
||||||
|
"close-order": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
@mintel:registry=https://npm.infra.mintel.me/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||||
|
|||||||
+12
-6
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
|
FROM git.infra.mintel.me/mmintel/nextjs:v1.8.20 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
@@ -25,9 +25,10 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
|
|||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
pnpm install --frozen-lockfile && \
|
pnpm store prune && \
|
||||||
|
pnpm install --no-frozen-lockfile && \
|
||||||
rm .npmrc
|
rm .npmrc
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
@@ -43,10 +44,15 @@ CMD ["pnpm", "dev:local"]
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||||
|
ENV RAYON_NUM_THREADS=3
|
||||||
|
ENV UV_THREADPOOL_SIZE=3
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 3: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:v1.8.20 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
|
|||||||
@@ -462,3 +462,4 @@ Proprietary - KLZ Cables
|
|||||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Last Updated**: December 27, 2025
|
**Last Updated**: December 27, 2025
|
||||||
|
Trigger rebuilding for x86 architecture.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||||
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||||
|
|
||||||
|
// Determine correct localized slug based on current locale
|
||||||
|
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${deSlug}`,
|
de: `${SITE_URL}/de/${deSlug}`,
|
||||||
en: `${SITE_URL}/en/${enSlug}`,
|
en: `${SITE_URL}/en/${enSlug}`,
|
||||||
@@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `${SITE_URL}/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect if accessed via a different locale's slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
if (correctSlug && correctSlug !== slug) {
|
||||||
|
redirect(`/${locale}/${correctSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
||||||
if (pageData.frontmatter.layout === 'fullBleed') {
|
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
|||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(url: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OG image:', url, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,12 +46,19 @@ export default async function Image({
|
|||||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||||
|
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||||
|
let base64Image: string | undefined = undefined;
|
||||||
|
if (featuredImage) {
|
||||||
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title={post.frontmatter.title}
|
||||||
description={post.frontmatter.excerpt}
|
description={post.frontmatter.excerpt}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={featuredImage}
|
image={base64Image || featuredImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
import {
|
||||||
|
getPostBySlug,
|
||||||
|
getAdjacentPosts,
|
||||||
|
getReadingTime,
|
||||||
|
extractLexicalHeadings,
|
||||||
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
@@ -32,7 +38,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -40,7 +46,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -54,12 +60,23 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user accessed this post using a slug from a different locale
|
||||||
|
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
|
||||||
|
if (post.slug && post.slug !== slug) {
|
||||||
|
redirect(`/${locale}/blog/${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
const rawTextContent = JSON.stringify(post.content);
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
@@ -81,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
|
quality={100}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,7 +106,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
@@ -105,8 +123,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -142,8 +160,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -224,10 +242,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
{/* Right Column: Sticky Sidebar - TOC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 lg:sticky lg:top-32">
|
||||||
{/* Future Payload Table of Contents Implementation */}
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+43
-36
@@ -73,7 +73,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 bg-neutral-dark/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,12 +84,9 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
{featuredPost &&
|
{featuredPost &&
|
||||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
featuredPost.frontmatter.public === false) && (
|
featuredPost.frontmatter.public === false) && (
|
||||||
<Badge
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
variant="neutral"
|
|
||||||
className="border border-white/30 bg-transparent text-white/80 shadow-none"
|
|
||||||
>
|
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
@@ -156,66 +153,76 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Grid for remaining posts */}
|
{/* Grid for remaining posts */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
<div className="grid grid-cols-1 gap-12">
|
||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 50}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block focus:outline-none"
|
||||||
|
>
|
||||||
<Card
|
<Card
|
||||||
tag="article"
|
tag="article"
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
|
||||||
>
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
style={{
|
style={{
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
}}
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, 100vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge
|
<Badge variant="accent" className="shadow-md">
|
||||||
variant="accent"
|
|
||||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
|
||||||
>
|
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
</div>
|
||||||
<div className="flex items-center gap-3 text-[10px] md:text-sm font-bold text-primary/70 mb-2 md:mb-4 tracking-widest uppercase">
|
|
||||||
<span>
|
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</span>
|
</time>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
|
||||||
post.frontmatter.public === false) && (
|
|
||||||
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
|
||||||
|
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
|
||||||
|
{post.frontmatter.excerpt && (
|
||||||
|
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
)}
|
||||||
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
|
||||||
|
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema';
|
|||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
import { getOGImageMetadata } from '@/lib/metadata';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import ContactMap from '@/components/ContactMap';
|
import ContactMap from '@/components/ContactMap';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -59,6 +60,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
|
|
||||||
|
// Get translated slug to redirect if user used incorrect static slug
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const headersList = await headers();
|
||||||
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
|
const currentSlug = urlPath.split('/').pop();
|
||||||
|
|
||||||
|
if (currentSlug) {
|
||||||
|
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
|
||||||
|
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
|
||||||
|
const { redirect } = await import('next/navigation');
|
||||||
|
redirect(`/${locale}/${contactSlugDe}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -189,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
{t('info.email')}
|
{t('info.email')}
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<ObfuscatedEmail
|
||||||
href="mailto:info@klz-cables.com"
|
email="info@klz-cables.com"
|
||||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
>
|
/>
|
||||||
info@klz-cables.com
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</address>
|
</address>
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
|||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { Suspense } from 'react';
|
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { config } from '@/lib/config';
|
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||||
export default async function Layout(props: {
|
export default async function Layout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
|||||||
let messages: Record<string, any> = {};
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch {
|
||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +90,7 @@ export default async function Layout(props: {
|
|||||||
'Home',
|
'Home',
|
||||||
'Error',
|
'Error',
|
||||||
'StandardPage',
|
'StandardPage',
|
||||||
|
'Brochure',
|
||||||
];
|
];
|
||||||
const clientMessages: Record<string, any> = {};
|
const clientMessages: Record<string, any> = {};
|
||||||
for (const key of clientKeys) {
|
for (const key of clientKeys) {
|
||||||
@@ -160,6 +160,8 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
|
|
||||||
|
<AutoBrochureModal />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+96
-25
@@ -1,33 +1,86 @@
|
|||||||
'use client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { useEffect } from 'react';
|
import { getPayload } from 'payload';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import configPromise from '@payload-config';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { headers } from 'next/headers';
|
||||||
|
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default async function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = await getTranslations('Error.notFound');
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Try to determine the requested path
|
||||||
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
const headersList = await headers();
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
type: '404_not_found',
|
|
||||||
path: errorUrl,
|
let suggestedUrl = null;
|
||||||
|
let suggestedLang = null;
|
||||||
|
|
||||||
|
// If we have a path, try to see if the last segment (slug) exists in ANY locale
|
||||||
|
if (urlPath) {
|
||||||
|
const slug = urlPath.split('/').filter(Boolean).pop();
|
||||||
|
if (slug) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Check posts
|
||||||
|
const postRes = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Explicitly send the 404 to Sentry so we have visibility into broken links
|
// Check products
|
||||||
import('@sentry/nextjs').then((Sentry) => {
|
const productRes =
|
||||||
Sentry.withScope((scope) => {
|
postRes.docs.length === 0
|
||||||
scope.setTag('status_code', '404');
|
? await payload.find({
|
||||||
scope.setTag('path', errorUrl);
|
collection: 'products',
|
||||||
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning');
|
where: { slug: { equals: slug } },
|
||||||
});
|
locale: 'all',
|
||||||
});
|
limit: 1,
|
||||||
}, [trackEvent]);
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
// Check pages
|
||||||
|
const pageRes =
|
||||||
|
postRes.docs.length === 0 && productRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
|
||||||
|
|
||||||
|
if (anyDoc) {
|
||||||
|
// If the doc exists, we can figure out its native locale or
|
||||||
|
// offer the alternative locale (if we are in 'de', offer 'en')
|
||||||
|
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
|
||||||
|
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||||
|
|
||||||
|
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
|
||||||
|
|
||||||
|
// Reconstruct the URL for the alternative locale
|
||||||
|
const pathParts = urlPath.split('/').filter(Boolean);
|
||||||
|
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
|
||||||
|
pathParts[0] = alternativeLocale;
|
||||||
|
} else {
|
||||||
|
pathParts.unshift(alternativeLocale);
|
||||||
|
}
|
||||||
|
suggestedUrl = '/' + pathParts.join('/');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore Payload errors in 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ClientNotFoundTracker path={urlPath} />
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
{/* Industrial Background Element */}
|
{/* Industrial Background Element */}
|
||||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
@@ -48,13 +101,30 @@ export default function NotFound() {
|
|||||||
{t('title')}
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
|
|
||||||
|
{suggestedUrl && (
|
||||||
|
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
|
||||||
|
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-primary font-bold mb-2 text-lg">
|
||||||
|
Did you mean to visit the {suggestedLang} version?
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm mb-4">
|
||||||
|
This page exists, but in another language.
|
||||||
|
</p>
|
||||||
|
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
|
||||||
|
Go to {suggestedLang} Version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/contact" variant="outline" size="lg">
|
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||||
Contact Support
|
Contact Support
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,5 +132,6 @@ export default function NotFound() {
|
|||||||
{/* Decorative Industrial Line */}
|
{/* Decorative Industrial Line */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||||
</Container>
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import JsonLd from '@/components/JsonLd';
|
|||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ProductTabs from '@/components/ProductTabs';
|
||||||
|
import ExcelDownload from '@/components/ExcelDownload';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getDatasheetPath } from '@/lib/datasheets';
|
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -14,7 +15,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
import PayloadRichText from '@/components/PayloadRichText';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
@@ -75,11 +76,13 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
|
const currentLocalePath = await getLocalizedPath(locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
@@ -90,7 +93,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -114,7 +117,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
'high-voltage-cables',
|
'high-voltage-cables',
|
||||||
'solar-cables',
|
'solar-cables',
|
||||||
];
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const translatedSlugsForLocale = await Promise.all(
|
||||||
|
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the requested slugs don't exactly match the translated slugs for the current locale
|
||||||
|
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
|
||||||
|
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
|
||||||
|
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSlug = fileSlugs[fileSlugs.length - 1];
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
@@ -125,11 +140,26 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
? t(`categories.${categoryKey}.title`)
|
? t(`categories.${categoryKey}.title`)
|
||||||
: fileSlug;
|
: fileSlug;
|
||||||
|
|
||||||
const filteredProducts = allProducts.filter((p) =>
|
const filteredProducts = allProducts.filter((p) => {
|
||||||
p.frontmatter.categories.some(
|
const firstCat = p.frontmatter.categories[0] || '';
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
),
|
let pFileSlug = 'low-voltage-cables';
|
||||||
);
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
pFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
pFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
pFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
return pFileSlug === fileSlug;
|
||||||
|
});
|
||||||
|
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
@@ -249,6 +279,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||||
|
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
const categorySlug = slug[0];
|
const categorySlug = slug[0];
|
||||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
@@ -293,6 +324,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -312,6 +345,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
productImage={product.frontmatter.images?.[0]}
|
productImage={product.frontmatter.images?.[0]}
|
||||||
datasheetPath={datasheetPath}
|
datasheetPath={datasheetPath}
|
||||||
|
excelPath={excelPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -324,29 +358,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
sku={product.frontmatter.sku}
|
sku={product.frontmatter.sku}
|
||||||
/>
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}`}
|
href={`/${locale}/${productsSlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||||
>
|
>
|
||||||
{categoryTitle}
|
{categoryTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
@@ -357,7 +393,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{t('englishVersion')}
|
{t('englishVersion')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -368,10 +404,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Heading level={1} className="text-white mb-8 uppercase">
|
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||||
{product.frontmatter.title}
|
{product.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,11 +421,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative -mt-32 mb-32 animate-slide-up"
|
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
@@ -424,10 +460,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||||
{/* 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-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||||
{descriptionChildren.length > 0 ? (
|
{descriptionChildren.length > 0 ? (
|
||||||
<PayloadRichText data={descriptionContent} />
|
<PayloadRichText data={descriptionContent} />
|
||||||
) : product.frontmatter.description ? (
|
) : product.frontmatter.description ? (
|
||||||
@@ -435,6 +471,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{product.application?.root?.children?.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<PayloadRichText data={product.application} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,7 +485,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width Technical Data Below */}
|
{/* Full-width Technical Data Below */}
|
||||||
<div className="mt-16 pt-16 border-t-0">
|
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
<PayloadRichText data={technicalContent} />
|
<PayloadRichText data={technicalContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -457,7 +499,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl">
|
||||||
|
<DatasheetDownload
|
||||||
|
datasheetPath={datasheetPath}
|
||||||
|
className="mt-0 w-full sm:w-auto"
|
||||||
|
/>
|
||||||
|
{excelPath && (
|
||||||
|
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -501,7 +551,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
|
|||||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
{t('michael.role')}
|
{t('michael.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
{t('michael.quote')}
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('michael.name')}
|
alt={t('michael.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('klaus.name')}
|
alt={t('klaus.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
{t('klaus.role')}
|
{t('klaus.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
{t('klaus.quote')}
|
{t('klaus.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
|
export async function requestBrochureAction(formData: FormData) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||||
|
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in services.analytics) {
|
||||||
|
(services.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
services.analytics.track('brochure-request-attempt');
|
||||||
|
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const locale = (formData.get('locale') as string) || 'en';
|
||||||
|
|
||||||
|
// Anti-spam Honeypot Check
|
||||||
|
const honeypot = formData.get('company_website') as string;
|
||||||
|
if (honeypot) {
|
||||||
|
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||||
|
// Silently succeed to fool the bot without doing actual work
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
logger.warn('Missing email in brochure request');
|
||||||
|
return { success: false, error: 'Missing email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return { success: false, error: 'Invalid email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Save to CMS
|
||||||
|
try {
|
||||||
|
const { getPayload } = await import('payload');
|
||||||
|
const configPromise = (await import('@payload-config')).default;
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'form-submissions',
|
||||||
|
data: {
|
||||||
|
name: email.split('@')[0],
|
||||||
|
email,
|
||||||
|
message: `Brochure download request (${locale})`,
|
||||||
|
type: 'brochure_download' as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||||
|
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notify via Gotify
|
||||||
|
try {
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '📑 Brochure Download Request',
|
||||||
|
message: `New brochure download request from ${email} (${locale})`,
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send notification', { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send Brochure via Email
|
||||||
|
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||||
|
const { render } = await import('@mintel/mail');
|
||||||
|
const React = await import('react');
|
||||||
|
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
React.createElement(BrochureDeliveryEmail, {
|
||||||
|
_email: email,
|
||||||
|
brochureUrl,
|
||||||
|
locale: locale as 'en' | 'de',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResult.success) {
|
||||||
|
logger.info('Brochure email sent successfully', { email });
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||||
|
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||||
|
action: 'requestBrochureAction_email',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Exception while sending brochure email', { error });
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Track success
|
||||||
|
services.analytics.track('brochure-request-success', {
|
||||||
|
locale,
|
||||||
|
delivery_method: 'email',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
// Track attempt
|
// Track attempt
|
||||||
services.analytics.track('contact-form-attempt');
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
|
// Anti-spam Honeypot Check
|
||||||
|
const honeypot = formData.get('company_website') as string;
|
||||||
|
if (honeypot) {
|
||||||
|
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
|
||||||
|
// Silently succeed to fool the bot without doing actual work
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const message = formData.get('message') as string;
|
const message = formData.get('message') as string;
|
||||||
@@ -72,6 +80,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: 'New Contact Form Submission';
|
: 'New Contact Form Submission';
|
||||||
const confirmationSubject = 'Thank you for your inquiry';
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
const isTestSubmission = email === 'testing@mintel.me';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2a. Send notification to Mintel/Client
|
// 2a. Send notification to Mintel/Client
|
||||||
@@ -84,6 +93,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
const notificationResult = await sendEmail({
|
const notificationResult = await sendEmail({
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
@@ -105,6 +115,9 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
{ action: 'sendContactFormAction_notification', email },
|
{ action: 'sendContactFormAction_notification', email },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping notification email for test submission', { email });
|
||||||
|
}
|
||||||
|
|
||||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
const confirmationHtml = await render(
|
const confirmationHtml = await render(
|
||||||
@@ -115,6 +128,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isTestSubmission) {
|
||||||
const confirmationResult = await sendEmail({
|
const confirmationResult = await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: confirmationSubject,
|
subject: confirmationSubject,
|
||||||
@@ -136,6 +150,9 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
{ action: 'sendContactFormAction_confirmation', email },
|
{ action: 'sendContactFormAction_confirmation', email },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping confirmation email for test submission', { email });
|
||||||
|
}
|
||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
await services.notifications.notify({
|
await services.notifications.notify({
|
||||||
|
|||||||
@@ -1,9 +1,41 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep CMS Health Check
|
||||||
|
* Validates that Payload CMS can actually query the database.
|
||||||
|
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||||
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
const checks: Record<string, string> = {};
|
||||||
// Further DB health checks can be implemented via Payload Local API later.
|
|
||||||
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
checks.init = 'ok';
|
||||||
|
|
||||||
|
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||||
|
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||||
|
for (const collection of collections) {
|
||||||
|
try {
|
||||||
|
await payload.find({ collection, limit: 1, locale: 'en' });
|
||||||
|
checks[collection] = 'ok';
|
||||||
|
} catch (e: any) {
|
||||||
|
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: e.message?.substring(0, 200), checks },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -58,9 +58,24 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const product of productsMetadata) {
|
for (const product of productsMetadata) {
|
||||||
if (!product.frontmatter || !product.slug) continue;
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
|
|
||||||
const category =
|
const firstCat = product.frontmatter.categories[0] || '';
|
||||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
let categoryFileSlug = 'low-voltage-cables';
|
||||||
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
categoryFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
|
||||||
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||||
|
|
||||||
|
export default function AutoBrochureModal() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user has already seen or interacted with the modal
|
||||||
|
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||||
|
|
||||||
|
if (!hasSeenModal) {
|
||||||
|
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
// Mark as seen so it doesn't bother them again on next page load
|
||||||
|
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||||
|
* The full-catalog PDF is ONLY revealed after email submission.
|
||||||
|
* No direct download link is exposed anywhere.
|
||||||
|
*/
|
||||||
|
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||||
|
const t = useTranslations('Brochure');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn(className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||||
|
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||||
|
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Green top accent */}
|
||||||
|
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||||
|
PDF Katalog
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||||
|
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('ctaTitle')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface BrochureModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||||
|
const t = useTranslations('Brochure');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// Close on escape + lock scroll + focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
// Auto-focus input when opened
|
||||||
|
const firstInput = document.getElementById('brochure-email');
|
||||||
|
if (firstInput) {
|
||||||
|
setTimeout(() => firstInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && modalRef.current) {
|
||||||
|
const focusable = modalRef.current.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
) as NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
last.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
first.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
// Strict overflow lock on mobile as well
|
||||||
|
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formRef.current) return;
|
||||||
|
|
||||||
|
setState('submitting');
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(formRef.current);
|
||||||
|
formData.set('locale', locale);
|
||||||
|
|
||||||
|
const result = await requestBrochureAction(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setState('success');
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||||
|
file_type: 'brochure',
|
||||||
|
location: 'brochure_modal',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState('error');
|
||||||
|
setErrorMsg(result.error || 'Something went wrong');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState('error');
|
||||||
|
setErrorMsg('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setState('idle');
|
||||||
|
setErrorMsg('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Panel */}
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Accent bar at top */}
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||||
|
aria-label={t('close')}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-8 pt-7">
|
||||||
|
{/* Icon + Header */}
|
||||||
|
<div className="mb-7">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-[#82ed20]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||||
|
{t('title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'success' ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-[#82ed20]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-[#82ed20]">
|
||||||
|
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50 mt-0.5">
|
||||||
|
{locale === 'de'
|
||||||
|
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||||
|
: 'Please check your inbox.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||||
|
>
|
||||||
|
{t('close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-5">
|
||||||
|
<label
|
||||||
|
htmlFor="brochure-email"
|
||||||
|
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||||
|
>
|
||||||
|
{t('emailLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="brochure-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder={t('emailPlaceholder')}
|
||||||
|
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||||
|
disabled={state === 'submitting'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'error' && errorMsg && (
|
||||||
|
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={state === 'submitting'}
|
||||||
|
className={cn(
|
||||||
|
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||||
|
state === 'submitting'
|
||||||
|
? 'bg-white/10 text-white/40 cursor-wait'
|
||||||
|
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||||
|
{t('privacyNote')}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modal, document.body);
|
||||||
|
}
|
||||||
@@ -139,6 +139,15 @@ export default function ContactForm() {
|
|||||||
{t('form.title')}
|
{t('form.title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||||
|
{/* Anti-spam Honeypot */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="company_website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||||
|
|
||||||
{/* Inner Content */}
|
{/* Inner Content */}
|
||||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||||
{/* Icon Container */}
|
{/* Icon Container */}
|
||||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
<svg
|
<svg
|
||||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||||
PDF Datasheet
|
PDF Datasheet
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||||
<svg
|
<svg
|
||||||
className="h-6 w-6"
|
className="h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface ExcelDownloadProps {
|
||||||
|
excelPath: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||||
|
const t = useTranslations('Products');
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||||
|
<a
|
||||||
|
href={excelPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: excelPath.split('/').pop(),
|
||||||
|
file_path: excelPath,
|
||||||
|
file_type: 'excel',
|
||||||
|
location: 'product_page',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
{/* Animated Background Gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||||
|
|
||||||
|
{/* Inner Content */}
|
||||||
|
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||||
|
{/* Icon Container */}
|
||||||
|
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
{/* Spreadsheet/Table Icon */}
|
||||||
|
<svg
|
||||||
|
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||||
|
Excel Datasheet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||||
|
{t('downloadExcel')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||||
|
{t('downloadExcelDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Icon */}
|
||||||
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
-13
@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
import FooterBrochureForm from './FooterBrochureForm';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
@@ -15,14 +16,14 @@ export default function Footer() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<h2 className="sr-only">Footer Navigation</h2>
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-block group"
|
className="inline-block group"
|
||||||
@@ -67,9 +68,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links Columns */}
|
{/* Legal Column */}
|
||||||
<div className="lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('legal')}
|
{t('legal')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -121,8 +122,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Company Column */}
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('company')}
|
{t('company')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -189,9 +191,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('recentPosts')}
|
{t('recentPosts')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
@@ -242,7 +244,11 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
<div className="mb-12 md:mb-16">
|
||||||
|
<FooterBrochureForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FooterBrochureForm({ className }: Props) {
|
||||||
|
const t = useTranslations('Brochure');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formRef.current) return;
|
||||||
|
setPhase('loading');
|
||||||
|
|
||||||
|
const fd = new FormData(formRef.current);
|
||||||
|
fd.set('locale', locale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await requestBrochureAction(fd);
|
||||||
|
if (res.success) {
|
||||||
|
setPhase('success');
|
||||||
|
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||||
|
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||||
|
file_type: 'brochure',
|
||||||
|
location: 'footer_inline',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErr(res.error || 'Error');
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErr('Network error');
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'success') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold mb-1">
|
||||||
|
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
{locale === 'de'
|
||||||
|
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||||
|
: 'We have just sent the catalog to your email.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 max-w-xl">
|
||||||
|
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||||
|
{t('ctaTitle')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||||
|
>
|
||||||
|
{/* Anti-spam Honeypot */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="company_website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-64">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder={t('emailPlaceholder')}
|
||||||
|
disabled={phase === 'loading'}
|
||||||
|
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={phase === 'loading'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||||
|
phase === 'loading'
|
||||||
|
? 'bg-white/10 text-white/40 cursor-wait'
|
||||||
|
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{phase === 'error' && err && (
|
||||||
|
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+28
-6
@@ -141,7 +141,8 @@ export default function Header() {
|
|||||||
{
|
{
|
||||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||||
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,9 +153,7 @@ export default function Header() {
|
|||||||
<>
|
<>
|
||||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<div
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||||
className="flex-shrink-0 group touch-target fill-mode-both"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}`}
|
href={`/${currentLocale}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -336,11 +335,12 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl 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',
|
||||||
@@ -352,6 +352,29 @@ export default function Header() {
|
|||||||
ref={mobileMenuRef}
|
ref={mobileMenuRef}
|
||||||
inert={isMobileMenuOpen ? undefined : true}
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
>
|
>
|
||||||
|
{/* Close Button inside overlay */}
|
||||||
|
<div className="flex justify-end p-6 pt-8">
|
||||||
|
<button
|
||||||
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
|
aria-label={t('toggleMenu')}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
{menuItems.map((item, idx) => (
|
{menuItems.map((item, idx) => (
|
||||||
<div
|
<div
|
||||||
@@ -444,7 +467,6 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedEmailProps {
|
||||||
|
email: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect email addresses from simple spambots.
|
||||||
|
* It uses client-side mounting to render the actual email address,
|
||||||
|
* making it harder for static crawlers to harvest.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once mounted on the client, render the real mailto link
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className={className}>
|
||||||
|
{children || email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedPhoneProps {
|
||||||
|
phone: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect phone numbers from simple spambots.
|
||||||
|
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format phone number for tel: link (remove spaces, etc.)
|
||||||
|
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
// e.g. +49 881 925 [at] 37298
|
||||||
|
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || obscured}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={telLink} className={className}>
|
||||||
|
{children || phone}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
+236
-77
@@ -1,7 +1,7 @@
|
|||||||
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
||||||
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Suspense } from 'react';
|
import { Suspense, Fragment } from 'react';
|
||||||
|
|
||||||
// Import all custom React components that were previously mapped via Markdown
|
// Import all custom React components that were previously mapped via Markdown
|
||||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||||
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
|
|||||||
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
|
||||||
|
|
||||||
import HomeHero from '@/components/home/Hero';
|
import HomeHero from '@/components/home/Hero';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import ProductCategories from '@/components/home/ProductCategories';
|
||||||
@@ -36,96 +38,246 @@ import GallerySection from '@/components/home/GallerySection';
|
|||||||
import VideoSection from '@/components/home/VideoSection';
|
import VideoSection from '@/components/home/VideoSection';
|
||||||
import CTA from '@/components/home/CTA';
|
import CTA from '@/components/home/CTA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a text string on \n and intersperses <br /> elements.
|
||||||
|
* This is needed because Lexical stores newlines as literal \n characters inside
|
||||||
|
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
|
||||||
|
*/
|
||||||
|
function textWithLineBreaks(text: string, key: string) {
|
||||||
|
const parts = text.split('\n');
|
||||||
|
if (parts.length === 1) return text;
|
||||||
|
return parts.map((part, i) => (
|
||||||
|
<Fragment key={`${key}-${i}`}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && <br />}
|
||||||
|
</Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const jsxConverters: JSXConverters = {
|
const jsxConverters: JSXConverters = {
|
||||||
...defaultJSXConverters,
|
...defaultJSXConverters,
|
||||||
// Let the default converters handle text nodes to preserve valid formatting
|
// Handle Lexical linebreak nodes (explicit shift+enter)
|
||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
linebreak: () => <br />,
|
||||||
|
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
|
||||||
text: ({ node }: any) => {
|
text: ({ node }: any) => {
|
||||||
const text = node.text;
|
let content: React.ReactNode = node.text || '';
|
||||||
// Handle markdown-style lists embedded in text nodes from Markdown migration
|
// Split newlines first
|
||||||
if (text && text.includes('\n- ')) {
|
if (typeof content === 'string' && content.includes('\n')) {
|
||||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
|
||||||
// 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>;
|
|
||||||
})}
|
// Obfuscate emails in text content
|
||||||
</ul>
|
if (typeof content === 'string' && content.includes('@')) {
|
||||||
</>
|
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
||||||
|
const parts = content.split(emailRegex);
|
||||||
|
content = parts.map((part, i) => {
|
||||||
|
if (part.match(emailRegex)) {
|
||||||
|
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
|
||||||
|
if (typeof content === 'string' && content.match(/\+\d+/)) {
|
||||||
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
const parts = content.split(phoneRegex);
|
||||||
|
content = parts.map((part, i) => {
|
||||||
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array content (from previous mappings)
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
content = content.map((item, idx) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
// Re-apply phone regex to strings in array
|
||||||
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
if (item.match(phoneRegex)) {
|
||||||
|
const parts = item.split(phoneRegex);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Lexical formatting flags
|
||||||
|
if (node.format) {
|
||||||
|
if (node.format & 1) content = <strong>{content}</strong>;
|
||||||
|
if (node.format & 2) content = <em>{content}</em>;
|
||||||
|
if (node.format & 8) content = <u>{content}</u>;
|
||||||
|
if (node.format & 4) content = <s>{content}</s>;
|
||||||
|
if (node.format & 16)
|
||||||
|
content = (
|
||||||
|
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
|
||||||
|
{content}
|
||||||
|
</code>
|
||||||
);
|
);
|
||||||
|
if (node.format & 32) content = <sub>{content}</sub>;
|
||||||
|
if (node.format & 64) content = <sup>{content}</sup>;
|
||||||
}
|
}
|
||||||
|
return <>{content}</>;
|
||||||
|
},
|
||||||
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||||
|
paragraph: ({ node, nodesToJSX }: any) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||||
|
{nodesToJSX({ nodes: node.children })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||||
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
const tag = node?.tag;
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
// Extract text to generate an ID for the TOC
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
// Lexical children might contain various nodes; we need a plain text representation
|
||||||
}
|
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||||
|
const id = textContent
|
||||||
|
? textContent
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[*_`]/g, '')
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Handle markdown-style links [text](url) from Markdown migration
|
if (tag === 'h1')
|
||||||
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
return (
|
||||||
const parts: React.ReactNode[] = [];
|
<h2
|
||||||
const remaining = text;
|
id={id}
|
||||||
let key = 0;
|
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
>
|
||||||
let match;
|
{children}
|
||||||
let lastIndex = 0;
|
</h2>
|
||||||
while ((match = linkRegex.exec(remaining)) !== null) {
|
);
|
||||||
if (match.index > lastIndex) {
|
if (tag === 'h2')
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
return (
|
||||||
}
|
<h3
|
||||||
parts.push(
|
id={id}
|
||||||
<a
|
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||||
key={key++}
|
>
|
||||||
href={match[2]}
|
{children}
|
||||||
target="_blank"
|
</h3>
|
||||||
rel="noopener noreferrer"
|
);
|
||||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
if (tag === 'h3')
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
if (tag === 'h4')
|
||||||
|
return (
|
||||||
|
<h5
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
if (tag === 'h5')
|
||||||
|
return (
|
||||||
|
<h6
|
||||||
|
id={id}
|
||||||
|
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
>
|
>
|
||||||
{match[1]}
|
{children}
|
||||||
</a>,
|
</h6>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.listType === 'number') {
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
);
|
);
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
}
|
||||||
if (lastIndex < remaining.length) {
|
if (node?.listType === 'check') {
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
|
||||||
}
|
}
|
||||||
return <>{parts}</>;
|
return (
|
||||||
|
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listitem: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.checked != null) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={node.checked}
|
||||||
|
readOnly
|
||||||
|
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||||
|
},
|
||||||
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
return (
|
||||||
|
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
// Handling Payload CMS link nodes
|
||||||
|
const href = node?.fields?.url || node?.url || '#';
|
||||||
|
const newTab = node?.fields?.newTab || node?.newTab;
|
||||||
|
|
||||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
if (href.startsWith('mailto:')) {
|
||||||
if (text && text.includes('\n')) {
|
const email = href.replace('mailto:', '');
|
||||||
const lines = text.split('\n');
|
return (
|
||||||
return (
|
<ObfuscatedEmail
|
||||||
<>
|
email={email}
|
||||||
{lines.map((line: string, i: number) => (
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
<span key={i}>
|
/>
|
||||||
{line}
|
|
||||||
{i < lines.length - 1 && <br />}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.format === 1) return <strong>{text}</strong>;
|
return (
|
||||||
if (node.format === 2) return <em>{text}</em>;
|
<a
|
||||||
return <span>{text}</span>;
|
href={href}
|
||||||
|
target={newTab ? '_blank' : undefined}
|
||||||
|
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||||
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc)
|
|
||||||
h1: ({ children }: any) => <h2 className="text-3xl md:text-4xl font-bold my-6">{children}</h2>,
|
|
||||||
h2: ({ children }: any) => <h3 className="text-2xl md:text-3xl font-bold my-5">{children}</h3>,
|
|
||||||
h3: ({ children }: any) => <h4 className="text-xl md:text-2xl font-bold my-4">{children}</h4>,
|
|
||||||
|
|
||||||
blocks: {
|
blocks: {
|
||||||
// ... preserved existing blocks ...
|
// ... preserved existing blocks ...
|
||||||
@@ -170,10 +322,10 @@ const jsxConverters: JSXConverters = {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
technicalGrid: ({ node }: any) => (
|
technicalGrid: ({ node }: any) => (
|
||||||
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
|
<TechnicalGrid title={node?.fields?.title} items={node?.fields?.items} />
|
||||||
),
|
),
|
||||||
'block-technicalGrid': ({ node }: any) => {
|
'block-technicalGrid': ({ node }: any) => {
|
||||||
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
|
if (!node?.fields) return null;
|
||||||
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
||||||
},
|
},
|
||||||
highlightBox: ({ node }: any) => (
|
highlightBox: ({ node }: any) => (
|
||||||
@@ -246,7 +398,9 @@ const jsxConverters: JSXConverters = {
|
|||||||
{node.fields.title}
|
{node.fields.title}
|
||||||
</SplitHeading>
|
</SplitHeading>
|
||||||
),
|
),
|
||||||
productTabs: ({ node }: any) => (
|
productTabs: ({ node }: any) => {
|
||||||
|
if (!node?.fields) return null;
|
||||||
|
return (
|
||||||
<ProductTabs
|
<ProductTabs
|
||||||
technicalData={
|
technicalData={
|
||||||
<ProductTechnicalData
|
<ProductTechnicalData
|
||||||
@@ -259,7 +413,8 @@ const jsxConverters: JSXConverters = {
|
|||||||
>
|
>
|
||||||
<></>
|
<></>
|
||||||
</ProductTabs>
|
</ProductTabs>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
'block-productTabs': ({ node }: any) => (
|
'block-productTabs': ({ node }: any) => (
|
||||||
<ProductTabs
|
<ProductTabs
|
||||||
technicalData={
|
technicalData={
|
||||||
@@ -1009,6 +1164,10 @@ export default function PayloadRichText({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.root?.children?.length > 0) {
|
||||||
|
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicConverters: JSXConverters = {
|
const dynamicConverters: JSXConverters = {
|
||||||
...jsxConverters,
|
...jsxConverters,
|
||||||
blocks: {
|
blocks: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
|
import ExcelDownload from '@/components/ExcelDownload';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
|||||||
productName: string;
|
productName: string;
|
||||||
productImage?: string;
|
productImage?: string;
|
||||||
datasheetPath?: string | null;
|
datasheetPath?: string | null;
|
||||||
|
excelPath?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
|||||||
productName,
|
productName,
|
||||||
productImage,
|
productImage,
|
||||||
datasheetPath,
|
datasheetPath,
|
||||||
|
excelPath,
|
||||||
className,
|
className,
|
||||||
}: ProductSidebarProps) {
|
}: ProductSidebarProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
|||||||
|
|
||||||
{/* Datasheet Download */}
|
{/* Datasheet Download */}
|
||||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||||
|
|
||||||
|
{/* Excel Download – right below datasheet */}
|
||||||
|
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||||
|
|
||||||
interface KeyValueItem {
|
interface KeyValueItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -38,29 +39,47 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
General Data
|
General Data
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => {
|
||||||
|
const formatted = formatTechnicalValue(item.value);
|
||||||
|
return (
|
||||||
<div key={idx} className="flex flex-col group">
|
<div key={idx} className="flex flex-col group">
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
{item.label}
|
{item.label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-lg font-semibold text-text-primary">
|
<dd className="text-lg font-semibold text-text-primary">
|
||||||
|
{formatted.isList ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{formatted.parts.map((p, pIdx) => (
|
||||||
|
<span
|
||||||
|
key={pIdx}
|
||||||
|
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{item.value}{' '}
|
{item.value}{' '}
|
||||||
{item.unit && (
|
{item.unit && (
|
||||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||||
{item.unit}
|
{item.unit}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -72,7 +91,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
@@ -83,7 +102,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||||
{table.metaItems.map((item, mIdx) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
@@ -98,10 +117,11 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* Scroll hint gradient on right edge for mobile */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
id={`voltage-table-${idx}`}
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<table className="min-w-full border-separate border-spacing-0">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
|
|||||||
@@ -41,10 +41,8 @@ export default async function RelatedProducts({
|
|||||||
];
|
];
|
||||||
const catFileSlug =
|
const catFileSlug =
|
||||||
categorySlugs.find((slug) => {
|
categorySlugs.find((slug) => {
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const title = t(`categories.${key}.title`);
|
|
||||||
return product.frontmatter.categories.some(
|
return product.frontmatter.categories.some(
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
|
||||||
);
|
);
|
||||||
}) || 'low-voltage-cables';
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||||
|
{/* Anti-spam Honeypot */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="company_website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2 !mt-0">
|
<div className="space-y-2 !mt-0">
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
<label htmlFor={emailId} className="sr-only">
|
<label htmlFor={emailId} className="sr-only">
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
|||||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AnalyticsShell() {
|
export default function AnalyticsShell() {
|
||||||
const [shouldLoad, setShouldLoad] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DynamicAnalyticsProvider />
|
<DynamicAnalyticsProvider />
|
||||||
<DynamicScrollDepthTracker />
|
<DynamicScrollDepthTracker />
|
||||||
|
<DynamicWebVitalsTracker />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
export default function ClientNotFoundTracker({ path }: { path: string }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('status_code', '404');
|
||||||
|
scope.setTag('path', path);
|
||||||
|
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [trackEvent, path]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReportWebVitals } from 'next/web-vitals';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVitalsTracker component.
|
||||||
|
*
|
||||||
|
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||||
|
* This provides "meaningful" page speed tracking by measuring real user
|
||||||
|
* experiences (LCP, CLS, INP, etc.).
|
||||||
|
*/
|
||||||
|
export default function WebVitalsTracker() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useReportWebVitals((metric) => {
|
||||||
|
const { name, value, id, label } = metric;
|
||||||
|
|
||||||
|
// Determine rating (simplified version of web-vitals standards)
|
||||||
|
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||||
|
|
||||||
|
if (name === 'LCP') {
|
||||||
|
if (value > 4000) rating = 'poor';
|
||||||
|
else if (value > 2500) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'CLS') {
|
||||||
|
if (value > 0.25) rating = 'poor';
|
||||||
|
else if (value > 0.1) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FID') {
|
||||||
|
if (value > 300) rating = 'poor';
|
||||||
|
else if (value > 100) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FCP') {
|
||||||
|
if (value > 3000) rating = 'poor';
|
||||||
|
else if (value > 1800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'TTFB') {
|
||||||
|
if (value > 1500) rating = 'poor';
|
||||||
|
else if (value > 800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'INP') {
|
||||||
|
if (value > 500) rating = 'poor';
|
||||||
|
else if (value > 200) rating = 'needs-improvement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Umami
|
||||||
|
trackEvent('web-vital', {
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||||
|
rating,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||||
|
|
||||||
interface TechnicalGridItem {
|
interface TechnicalGridItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -26,18 +26,34 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
|
const formatted = formatTechnicalValue(item.value);
|
||||||
|
return (
|
||||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||||
{item.value}
|
{formatted.isList ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{formatted.parts.map((p, pIdx) => (
|
||||||
|
<span
|
||||||
|
key={pIdx}
|
||||||
|
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
item.value
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface BrochureDeliveryEmailProps {
|
||||||
|
_email: string;
|
||||||
|
brochureUrl: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrochureDeliveryEmail = ({
|
||||||
|
_email,
|
||||||
|
brochureUrl,
|
||||||
|
locale = 'en',
|
||||||
|
}: BrochureDeliveryEmailProps) => {
|
||||||
|
const t =
|
||||||
|
locale === 'de'
|
||||||
|
? {
|
||||||
|
subject: 'Ihr KLZ Kabelkatalog',
|
||||||
|
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||||
|
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||||
|
button: 'Katalog herunterladen',
|
||||||
|
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
subject: 'Your KLZ Cable Catalog',
|
||||||
|
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||||
|
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||||
|
button: 'Download Catalog',
|
||||||
|
footer: 'This email was sent from klz-cables.com.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{t.subject}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={headerSection}>
|
||||||
|
<Heading style={h1}>{t.subject}</Heading>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={section}>
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>{t.greeting}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>{t.body}</Text>
|
||||||
|
|
||||||
|
<Section style={buttonContainer}>
|
||||||
|
<Button style={button} href={brochureUrl}>
|
||||||
|
{t.button}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={hr} />
|
||||||
|
</Section>
|
||||||
|
<Text style={footer}>{t.footer}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrochureDeliveryEmail;
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: '#f6f9fc',
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0 0 48px',
|
||||||
|
marginBottom: '64px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e6ebf1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerSection = {
|
||||||
|
backgroundColor: '#000d26',
|
||||||
|
padding: '32px 48px',
|
||||||
|
borderBottom: '4px solid #4da612',
|
||||||
|
};
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
margin: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const section = {
|
||||||
|
padding: '32px 48px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: '#333',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: '24px',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContainer = {
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '32px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = {
|
||||||
|
backgroundColor: '#4da612',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '16px 32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: '#e6ebf1',
|
||||||
|
margin: '20px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
color: '#8898aa',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '20px',
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@@ -20,23 +19,19 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
<div>
|
<div>
|
||||||
<Heading
|
<Heading
|
||||||
level={1}
|
level={1}
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||||
>
|
>
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
|
.replace(/<\/green>/g, '</span>'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
interface RecentPostsProps {
|
interface RecentPostsProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -13,7 +13,7 @@ interface RecentPostsProps {
|
|||||||
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const recentPosts = posts.slice(0, 3);
|
const recentPosts = posts.slice(0, 4);
|
||||||
|
|
||||||
if (recentPosts.length === 0) return null;
|
if (recentPosts.length === 0) return null;
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
const subtitle = data?.subtitle || t('latestNews');
|
const subtitle = data?.subtitle || t('latestNews');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral py-16 md:py-24">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||||
<Container>
|
<Container className="py-12 md:py-16">
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
|
||||||
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -35,74 +35,76 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||||
{recentPosts.map((post, idx) => (
|
{recentPosts.map((post, idx) => (
|
||||||
<li key={`${post.slug}-${idx}`}>
|
<li key={`${post.slug}-${idx}`} className="block">
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
<Link
|
||||||
<Card
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
tag="article"
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
|
||||||
>
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-64 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
}}
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<time
|
||||||
)}
|
dateTime={post.frontmatter.date}
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
suppressHydrationWarning
|
||||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
>
|
||||||
<time dateTime={post.frontmatter.date}>
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
['en', 'de'].includes(locale) ? locale : 'de',
|
||||||
|
{
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</time>
|
</time>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
</div>
|
||||||
{t('readMore')}
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
<svg
|
{t('readMore')}{' '}
|
||||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||||
fill="none"
|
→
|
||||||
stroke="currentColor"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Container>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection({ data }: { data?: any }) {
|
export default function VideoSection({ data }: { data?: any }) {
|
||||||
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||||
|
.replace(/<\/future>/g, '</span>'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
future: (chunks) => (
|
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||||
<span className="relative inline-block mx-2">
|
|
||||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="underline"
|
|
||||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"numberOfRuns": 3,
|
"numberOfRuns": 1,
|
||||||
"settings": {
|
"settings": {
|
||||||
"preset": "desktop",
|
"preset": "desktop",
|
||||||
"onlyCategories": [
|
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||||
"performance",
|
|
||||||
"accessibility",
|
|
||||||
"best-practices",
|
|
||||||
"seo"
|
|
||||||
],
|
|
||||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,7 +13,7 @@
|
|||||||
"categories:performance": [
|
"categories:performance": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"minScore": 0.9
|
"minScore": 0.7
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories:accessibility": [
|
"categories:accessibility": [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ services:
|
|||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
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-dev}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||||
UV_THREADPOOL_SIZE: "4"
|
UV_THREADPOOL_SIZE: "4"
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
CI: "true"
|
CI: "true"
|
||||||
|
|||||||
+17
-5
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
klz-app:
|
klz-app:
|
||||||
image: registry.infra.mintel.me/mintel/klz-2026:${IMAGE_TAG:-latest}
|
image: git.infra.mintel.me/mmintel/klz-2026:${IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- "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 – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
# 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}`)}) && PathRegexp(`^/(health|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|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}"
|
||||||
@@ -46,9 +46,21 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
|
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.regex=^https?://[^/]+/([a-z]{2}/)?login(.*)"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.replacement=https://${TRAEFIK_HOST:-klz-cables.com}/gatekeeper/login$${2}"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.permanent=false"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002"
|
||||||
|
|
||||||
klz-gatekeeper:
|
klz-gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
image: git.infra.mintel.me/mmintel/gatekeeper:testing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
@@ -66,8 +78,8 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Public Router (Login/Auth UI)
|
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(login|gatekeeper)(/.*)?`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathPrefix(`/gatekeeper`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// from being included in the initial JS bundle.
|
// from being included in the initial JS bundle.
|
||||||
export {};
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
+75
-9
@@ -59,18 +59,50 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
// First try: Find in the requested locale
|
||||||
const { docs } = await payload.find({
|
let { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
slug: { equals: slug },
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: If not found, try searching across all locales.
|
||||||
|
// This happens when a user uses the static language switcher
|
||||||
|
// e.g. switching from /en/blog/en-slug to /de/blog/en-slug.
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
const { docs: crossLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossLocaleDocs && crossLocaleDocs.length > 0) {
|
||||||
|
// Fetch the found document again, but strictly in the requested locale
|
||||||
|
// so we get the correctly translated fields (like the localized slug)
|
||||||
|
const { docs: correctLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossLocaleDocs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
docs = correctLocaleDocs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!docs || docs.length === 0) return null;
|
if (!docs || docs.length === 0) return null;
|
||||||
|
|
||||||
const doc = docs[0];
|
const doc = docs[0];
|
||||||
@@ -84,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -107,15 +139,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||||
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: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
sort: '-date',
|
sort: '-date',
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -255,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
|
|||||||
return { id, text: cleanText, level };
|
return { id, text: cleanText, level };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLexicalHeadings(
|
||||||
|
node: any,
|
||||||
|
headings: { id: string; text: string; level: number }[] = [],
|
||||||
|
): { id: string; text: string; level: number }[] {
|
||||||
|
if (!node) return headings;
|
||||||
|
|
||||||
|
if (node.type === 'heading' && node.tag) {
|
||||||
|
const level = parseInt(node.tag.replace('h', ''));
|
||||||
|
const text = getTextContentFromLexical(node);
|
||||||
|
if (text) {
|
||||||
|
headings.push({
|
||||||
|
id: generateHeadingId(text),
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContentFromLexical(node: any): string {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.text || '';
|
||||||
|
}
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map(getTextContentFromLexical).join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function createConfig() {
|
|||||||
isStaging: target === 'staging',
|
isStaging: target === 'staging',
|
||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
|
showDrafts: target === 'development' || target === 'testing' || target === 'staging',
|
||||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
@@ -116,6 +117,9 @@ export const config = {
|
|||||||
get isDevelopment() {
|
get isDevelopment() {
|
||||||
return getConfig().isDevelopment;
|
return getConfig().isDevelopment;
|
||||||
},
|
},
|
||||||
|
get showDrafts() {
|
||||||
|
return getConfig().showDrafts;
|
||||||
|
},
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return getConfig().baseUrl;
|
return getConfig().baseUrl;
|
||||||
},
|
},
|
||||||
|
|||||||
+67
-1
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
|||||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||||
|
|
||||||
// Subdirectories to search in
|
// Subdirectories to search in
|
||||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||||
|
|
||||||
// List of patterns to try for the current locale
|
// List of patterns to try for the current locale
|
||||||
|
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
|
||||||
const patterns = [
|
const patterns = [
|
||||||
`${slug}-${locale}.pdf`,
|
`${slug}-${locale}.pdf`,
|
||||||
`${slug}-2-${locale}.pdf`,
|
`${slug}-2-${locale}.pdf`,
|
||||||
`${slug}-3-${locale}.pdf`,
|
`${slug}-3-${locale}.pdf`,
|
||||||
|
`${slug}-mv-${locale}.pdf`,
|
||||||
|
`${slug}-hv-${locale}.pdf`,
|
||||||
`${normalizedSlug}-${locale}.pdf`,
|
`${normalizedSlug}-${locale}.pdf`,
|
||||||
`${normalizedSlug}-2-${locale}.pdf`,
|
`${normalizedSlug}-2-${locale}.pdf`,
|
||||||
`${normalizedSlug}-3-${locale}.pdf`,
|
`${normalizedSlug}-3-${locale}.pdf`,
|
||||||
|
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||||
|
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const subdir of subdirs) {
|
for (const subdir of subdirs) {
|
||||||
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
|||||||
`${slug}-en.pdf`,
|
`${slug}-en.pdf`,
|
||||||
`${slug}-2-en.pdf`,
|
`${slug}-2-en.pdf`,
|
||||||
`${slug}-3-en.pdf`,
|
`${slug}-3-en.pdf`,
|
||||||
|
`${slug}-mv-en.pdf`,
|
||||||
|
`${slug}-hv-en.pdf`,
|
||||||
`${normalizedSlug}-en.pdf`,
|
`${normalizedSlug}-en.pdf`,
|
||||||
`${normalizedSlug}-2-en.pdf`,
|
`${normalizedSlug}-2-en.pdf`,
|
||||||
`${normalizedSlug}-3-en.pdf`,
|
`${normalizedSlug}-3-en.pdf`,
|
||||||
|
`${normalizedSlug}-mv-en.pdf`,
|
||||||
|
`${normalizedSlug}-hv-en.pdf`,
|
||||||
|
];
|
||||||
|
for (const subdir of subdirs) {
|
||||||
|
for (const pattern of enPatterns) {
|
||||||
|
const relativePath = path.join(subdir, pattern);
|
||||||
|
const filePath = path.join(datasheetsDir, relativePath);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
return `/datasheets/${relativePath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the datasheet Excel path for a given product slug and locale.
|
||||||
|
* Checks public/datasheets for matching .xlsx files.
|
||||||
|
*/
|
||||||
|
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
|
||||||
|
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||||
|
|
||||||
|
if (!fs.existsSync(datasheetsDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||||
|
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
`${slug}-${locale}.xlsx`,
|
||||||
|
`${slug}-2-${locale}.xlsx`,
|
||||||
|
`${slug}-3-${locale}.xlsx`,
|
||||||
|
`${normalizedSlug}-${locale}.xlsx`,
|
||||||
|
`${normalizedSlug}-2-${locale}.xlsx`,
|
||||||
|
`${normalizedSlug}-3-${locale}.xlsx`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const subdir of subdirs) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const relativePath = path.join(subdir, pattern);
|
||||||
|
const filePath = path.join(datasheetsDir, relativePath);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
return `/datasheets/${relativePath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to English if locale is not 'en'
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const enPatterns = [
|
||||||
|
`${slug}-en.xlsx`,
|
||||||
|
`${slug}-2-en.xlsx`,
|
||||||
|
`${slug}-3-en.xlsx`,
|
||||||
|
`${normalizedSlug}-en.xlsx`,
|
||||||
|
`${normalizedSlug}-2-en.xlsx`,
|
||||||
|
`${normalizedSlug}-3-en.xlsx`,
|
||||||
];
|
];
|
||||||
for (const subdir of subdirs) {
|
for (const subdir of subdirs) {
|
||||||
for (const pattern of enPatterns) {
|
for (const pattern of enPatterns) {
|
||||||
|
|||||||
+70
-6
@@ -1,5 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface PageFrontmatter {
|
export interface PageFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData {
|
|||||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const result = await payload.find({
|
// Try finding exact match first
|
||||||
collection: 'pages' as any,
|
let result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const docs = result.docs as any[];
|
// Fallback: search ALL locales
|
||||||
if (!docs || docs.length === 0) return null;
|
if (result.docs.length === 0) {
|
||||||
return mapDoc(docs[0]);
|
const crossResult = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossResult.docs.length > 0) {
|
||||||
|
// Fetch missing exact match by internal id
|
||||||
|
result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossResult.docs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.docs.length > 0) {
|
||||||
|
const doc = result.docs[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: doc.slug,
|
||||||
|
frontmatter: {
|
||||||
|
title: doc.title,
|
||||||
|
excerpt: doc.excerpt || '',
|
||||||
|
featuredImage:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||||
|
: null,
|
||||||
|
focalX:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalX
|
||||||
|
: 50,
|
||||||
|
focalY:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalY
|
||||||
|
: 50,
|
||||||
|
layout:
|
||||||
|
doc.layout === 'fullBleed' || doc.layout === 'default'
|
||||||
|
? doc.layout
|
||||||
|
: ('default' as const),
|
||||||
|
},
|
||||||
|
content: doc.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,806 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
// ─── Brand Tokens ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
navy: '#001a4d',
|
||||||
|
navyDeep: '#000d26',
|
||||||
|
green: '#4da612',
|
||||||
|
greenLight: '#e8f5d8',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
|
gray400: '#9ca3af',
|
||||||
|
gray600: '#4b5563',
|
||||||
|
gray900: '#111827',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE = { w: 595.28, h: 841.89 }; // A4 in points
|
||||||
|
const MARGIN = 56;
|
||||||
|
const CONTENT_W = PAGE.w - MARGIN * 2;
|
||||||
|
const HEADER_H = 52;
|
||||||
|
const FOOTER_H = 48;
|
||||||
|
const BODY_TOP = HEADER_H + 40;
|
||||||
|
const BODY_BOTTOM = FOOTER_H + 24;
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BrochureProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
shortDescriptionHtml: string;
|
||||||
|
descriptionHtml: string;
|
||||||
|
applicationHtml?: string;
|
||||||
|
images: string[];
|
||||||
|
featuredImage: string | null;
|
||||||
|
sku: string;
|
||||||
|
slug: string;
|
||||||
|
categories: Array<{ name: string }>;
|
||||||
|
attributes: Array<{ name: string; options: string[] }>;
|
||||||
|
qrWebsite?: string | Buffer;
|
||||||
|
qrDatasheet?: string | Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrochureProps {
|
||||||
|
products: BrochureProduct[];
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
companyInfo: {
|
||||||
|
tagline: string;
|
||||||
|
values: Array<{ title: string; description: string }>;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
};
|
||||||
|
logoBlack?: string | Buffer;
|
||||||
|
logoWhite?: string | Buffer;
|
||||||
|
introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
|
||||||
|
marketingSections?: Array<{
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description?: string;
|
||||||
|
items?: Array<{ title: string; description: string }>;
|
||||||
|
highlights?: Array<{ value: string; label: string }>;
|
||||||
|
pullQuote?: string;
|
||||||
|
}>;
|
||||||
|
galleryImages?: Array<string | Buffer | undefined>;
|
||||||
|
messages?: Record<string, any>;
|
||||||
|
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const imgValid = (src?: string | Buffer): boolean => {
|
||||||
|
if (!src) return false;
|
||||||
|
if (Buffer.isBuffer(src)) return src.length > 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||||
|
catalog: 'Kabelkatalog',
|
||||||
|
subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.',
|
||||||
|
about: 'Über uns',
|
||||||
|
toc: 'Inhalt',
|
||||||
|
overview: 'Übersicht',
|
||||||
|
application: 'Anwendungsbereich',
|
||||||
|
specs: 'Technische Daten',
|
||||||
|
contact: 'Kontakt',
|
||||||
|
qrWeb: 'Details',
|
||||||
|
qrPdf: 'PDF',
|
||||||
|
values: 'Unsere Werte',
|
||||||
|
edition: 'Ausgabe',
|
||||||
|
page: 'Seite',
|
||||||
|
property: 'Eigenschaft',
|
||||||
|
value: 'Wert',
|
||||||
|
other: 'Sonstige'
|
||||||
|
} : {
|
||||||
|
catalog: 'Cable Catalog',
|
||||||
|
subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.',
|
||||||
|
about: 'About Us',
|
||||||
|
toc: 'Contents',
|
||||||
|
overview: 'Overview',
|
||||||
|
application: 'Application',
|
||||||
|
specs: 'Technical Data',
|
||||||
|
contact: 'Contact',
|
||||||
|
qrWeb: 'Details',
|
||||||
|
qrPdf: 'PDF',
|
||||||
|
values: 'Our Values',
|
||||||
|
edition: 'Edition',
|
||||||
|
page: 'Page',
|
||||||
|
property: 'Property',
|
||||||
|
value: 'Value',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Rich Text ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => {
|
||||||
|
const paragraphs = children.split('\n\n').filter(p => p.trim());
|
||||||
|
return (
|
||||||
|
<View style={{ gap }}>
|
||||||
|
{paragraphs.map((para, pIdx) => {
|
||||||
|
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
|
||||||
|
let rem = para;
|
||||||
|
while (rem.length > 0) {
|
||||||
|
const bm = rem.match(/\*\*(.+?)\*\*/);
|
||||||
|
const im = rem.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
|
||||||
|
const first = [bm, im].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
|
||||||
|
if (!first || first.index === undefined) { parts.push({ text: rem }); break; }
|
||||||
|
if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
|
||||||
|
parts.push({ text: first[1], bold: first[0].startsWith('**'), italic: !first[0].startsWith('**') });
|
||||||
|
rem = rem.substring(first.index + first[0].length);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text key={pIdx} style={style}>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<Text key={i} style={{
|
||||||
|
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: color || C.navyDeep } : {}),
|
||||||
|
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.green } : {}),
|
||||||
|
}}>{part.text}</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Shared Components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Thin brand bar at the top of every page
|
||||||
|
const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => (
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: MARGIN, paddingBottom: 12,
|
||||||
|
}} fixed>
|
||||||
|
{logo ? <Image src={logo} style={{ width: 56 }} /> : <Text style={{ fontSize: 14, fontWeight: 700, color: dark ? C.white : C.navy }}>KLZ</Text>}
|
||||||
|
{right && <Text style={{ fontSize: 7, fontWeight: 700, color: dark ? C.gray400 : C.gray400, letterSpacing: 1.2, textTransform: 'uppercase' }}>{right}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => (
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute', bottom: 20, left: MARGIN, right: MARGIN,
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
borderTopWidth: 0.5, borderTopColor: dark ? 'rgba(255,255,255,0.15)' : C.gray200, borderTopStyle: 'solid',
|
||||||
|
paddingTop: 8,
|
||||||
|
}} fixed>
|
||||||
|
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{left}</Text>
|
||||||
|
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{right}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Green accent bar
|
||||||
|
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
|
||||||
|
|
||||||
|
// ─── FadeImage ─────────────────────────────────────────────────────────────
|
||||||
|
// Simulates a gradient fade at one edge using stacked opacity bands.
|
||||||
|
// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles.
|
||||||
|
//
|
||||||
|
// 'position' param: which edge fades INTO the page background
|
||||||
|
// 'bottom' → image visible at top, fades down into bgColor
|
||||||
|
// 'top' → image visible at bottom, fades up into bgColor
|
||||||
|
// 'right' → image on left side, fades right into bgColor
|
||||||
|
//
|
||||||
|
// The component must be placed ABSOLUTE (position: 'absolute') on the page.
|
||||||
|
|
||||||
|
const FadeImage: React.FC<{
|
||||||
|
src: string | Buffer;
|
||||||
|
top?: number; left?: number; right?: number; bottom?: number;
|
||||||
|
width: number | string;
|
||||||
|
height: number;
|
||||||
|
fadeEdge: 'bottom' | 'top' | 'right' | 'left';
|
||||||
|
fadeSize?: number; // how many points the fade spans
|
||||||
|
bgColor: string;
|
||||||
|
opacity?: number; // overall image darkness (0–1, applied via overlay)
|
||||||
|
}> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => {
|
||||||
|
const STEPS = 40; // High number of overlapping bands
|
||||||
|
|
||||||
|
const bands = Array.from({ length: STEPS }, (_, i) => {
|
||||||
|
// i=0 is the widest band reaching deepest into the image.
|
||||||
|
// i=STEPS-1 is the narrowest band right at the fade edge.
|
||||||
|
// Because they all anchor at the edge and overlap, their opacity compounds.
|
||||||
|
// We use an ease-in curve for distance to make the fade look natural.
|
||||||
|
const t = 1.0 / STEPS;
|
||||||
|
const easeDist = Math.pow((i + 1) / STEPS, 1.2);
|
||||||
|
const dist = fadeSize * easeDist;
|
||||||
|
|
||||||
|
const style: any = {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
opacity: t,
|
||||||
|
};
|
||||||
|
|
||||||
|
// All bands anchor at the fade edge and extend inward by `dist`
|
||||||
|
if (fadeEdge === 'bottom') {
|
||||||
|
Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 });
|
||||||
|
} else if (fadeEdge === 'top') {
|
||||||
|
Object.assign(style, { left: 0, right: 0, height: dist, top: 0 });
|
||||||
|
} else if (fadeEdge === 'right') {
|
||||||
|
Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 });
|
||||||
|
} else {
|
||||||
|
Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ position: 'absolute', top, left, right, bottom, width, height }}>
|
||||||
|
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{/* Overlay using bgColor to "wash out" / dilute the image */}
|
||||||
|
{opacity > 0 && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: bgColor, opacity }} />}
|
||||||
|
{/* Gradient fade bands */}
|
||||||
|
{bands.map((s, i) => <View key={i} style={s} />)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PAGE 1: COVER
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const CoverPage: React.FC<{
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
introContent?: BrochureProps['introContent'];
|
||||||
|
logoWhite?: string | Buffer;
|
||||||
|
galleryImages?: Array<string | Buffer | undefined>;
|
||||||
|
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
||||||
|
const l = labels(locale);
|
||||||
|
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
||||||
|
const bg = galleryImages?.[0] || introContent?.heroImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||||
|
{/* Full-page background image with dark overlay */}
|
||||||
|
{imgValid(bg) && (
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||||
|
<Image src={bg!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.82 }} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!imgValid(bg) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||||
|
|
||||||
|
{/* Vertical accent stripe */}
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, width: 5, height: '40%', backgroundColor: C.green }} />
|
||||||
|
|
||||||
|
{/* Logo top-left */}
|
||||||
|
<View style={{ position: 'absolute', top: 56, left: MARGIN }}>
|
||||||
|
{imgValid(logoWhite) ? <Image src={logoWhite!} style={{ width: 120 }} /> : <Text style={{ fontSize: 24, fontWeight: 700, color: C.white }}>KLZ</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main title block — bottom third of page */}
|
||||||
|
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
||||||
|
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
|
||||||
|
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
|
||||||
|
{l.catalog}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: C.gray300, lineHeight: 1.8, marginTop: 16, maxWidth: 340 }}>
|
||||||
|
{introContent?.excerpt || l.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<View style={{ position: 'absolute', bottom: 40, left: MARGIN, right: MARGIN, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray400, letterSpacing: 1, textTransform: 'uppercase' }}>{l.edition} {dateStr}</Text>
|
||||||
|
<Text style={{ fontSize: 9, fontWeight: 700, color: C.green }}>www.klz-cables.com</Text>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PAGES 2–N: INFO PAGES (each marketing section = own page)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const InfoPage: React.FC<{
|
||||||
|
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||||
|
image?: string | Buffer;
|
||||||
|
logoBlack?: string | Buffer;
|
||||||
|
logoWhite?: string | Buffer;
|
||||||
|
dark?: boolean;
|
||||||
|
imagePosition?: 'top' | 'bottom-half';
|
||||||
|
}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => {
|
||||||
|
const bg = dark ? C.navyDeep : C.white;
|
||||||
|
const textColor = dark ? C.gray300 : C.gray600;
|
||||||
|
const titleColor = dark ? C.white : C.navyDeep;
|
||||||
|
const boldColor = dark ? C.white : C.navyDeep;
|
||||||
|
const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack;
|
||||||
|
|
||||||
|
// Image at top: 240pt tall, content starts below via paddingTop
|
||||||
|
const IMG_TOP_H = 240;
|
||||||
|
const bodyTopWithImg = imagePosition === 'top' && imgValid(image)
|
||||||
|
? IMG_TOP_H + 24 // content starts below image
|
||||||
|
: BODY_TOP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
|
{/* Absolute image — from page edge, fades into bg */}
|
||||||
|
{imgValid(image) && imagePosition === 'top' && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
top={0} left={0} right={0}
|
||||||
|
width="100%" height={IMG_TOP_H}
|
||||||
|
fadeEdge="bottom" fadeSize={120}
|
||||||
|
bgColor={bg}
|
||||||
|
opacity={dark ? 0.85 : 0.9} // EXTREMELY high opacity of bgColor to make image incredibly subtle
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imgValid(image) && imagePosition === 'bottom-half' && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
bottom={FOOTER_H + 20} left={0} right={0}
|
||||||
|
width="100%" height={340}
|
||||||
|
fadeEdge="top" fadeSize={140}
|
||||||
|
bgColor={bg}
|
||||||
|
opacity={dark ? 0.85 : 0.9} // Extremely subtle
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header — on top of image */}
|
||||||
|
<Header logo={headerLogo} right="KLZ Cables" dark={dark} />
|
||||||
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
|
||||||
|
|
||||||
|
{/* Content — pushed below image when top-position */}
|
||||||
|
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||||
|
|
||||||
|
{/* Label + Title */}
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
||||||
|
<Text style={{ fontSize: 24, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 16 }}>{section.title}</Text>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{section.description && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<RichText style={{ fontSize: 10, color: textColor, lineHeight: 1.7 }} gap={8} color={boldColor}>
|
||||||
|
{section.description}
|
||||||
|
</RichText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Highlights */}
|
||||||
|
{section.highlights && section.highlights.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||||
|
{section.highlights.map((h, i) => (
|
||||||
|
<View key={i} style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||||
|
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||||
|
paddingVertical: 12, paddingHorizontal: 12,
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 10, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||||
|
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pull quote */}
|
||||||
|
{section.pullQuote && (
|
||||||
|
<View style={{
|
||||||
|
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||||
|
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||||
|
„{section.pullQuote}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items — 2-column grid */}
|
||||||
|
{section.items && section.items.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||||
|
{section.items.map((item, i) => (
|
||||||
|
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||||
|
<Text style={{ fontSize: 9, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
||||||
|
<View style={{ width: 20, height: 1.5, backgroundColor: dark ? 'rgba(255,255,255,0.2)' : C.gray300, marginBottom: 6 }} />
|
||||||
|
<RichText style={{ fontSize: 8.5, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||||
|
{item.description}
|
||||||
|
</RichText>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// About page (first info page)
|
||||||
|
const AboutPage: React.FC<{
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
companyInfo: BrochureProps['companyInfo'];
|
||||||
|
logoBlack?: string | Buffer;
|
||||||
|
image?: string | Buffer;
|
||||||
|
messages?: Record<string, any>;
|
||||||
|
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||||
|
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
|
||||||
|
const l = labels(locale);
|
||||||
|
|
||||||
|
// Image at top: 200pt tall (smaller to leave more room for content)
|
||||||
|
const IMG_TOP_H = 200;
|
||||||
|
const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
|
||||||
|
|
||||||
|
// Pull directors content from messages if available
|
||||||
|
const team = messages?.Team || {};
|
||||||
|
const michael = team.michael;
|
||||||
|
const klaus = team.klaus;
|
||||||
|
const legacy = team.legacy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
|
{/* Top-aligned image fading into white bottom */}
|
||||||
|
{imgValid(image) && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
top={0} left={0} right={0}
|
||||||
|
width="100%" height={IMG_TOP_H}
|
||||||
|
fadeEdge="bottom" fadeSize={140}
|
||||||
|
bgColor={C.white}
|
||||||
|
opacity={0.92}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Header logo={logoBlack} right="KLZ Cables" />
|
||||||
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
|
{/* Content pushed below the fading image */}
|
||||||
|
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||||
|
<Text style={{ fontSize: 22, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 6 }}>KLZ Cables</Text>
|
||||||
|
<AccentBar />
|
||||||
|
|
||||||
|
<RichText style={{ fontSize: 10, color: C.gray900, lineHeight: 1.8 }} gap={8}>
|
||||||
|
{companyInfo.tagline}
|
||||||
|
</RichText>
|
||||||
|
|
||||||
|
{/* Company mission — makes immediately clear what KLZ does */}
|
||||||
|
<View style={{ marginTop: 12, marginBottom: 8 }}>
|
||||||
|
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
|
||||||
|
{locale === 'de'
|
||||||
|
? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.'
|
||||||
|
: 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'
|
||||||
|
}
|
||||||
|
</RichText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Directors — two-column */}
|
||||||
|
{(michael || klaus) && (
|
||||||
|
<View style={{ marginTop: 20 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>
|
||||||
|
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||||
|
{[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
|
||||||
|
<View key={i} style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||||
|
{p.photo && (
|
||||||
|
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||||
|
)}
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{p.data.description}</Text>
|
||||||
|
{p.data.quote && (
|
||||||
|
<View style={{ borderLeftWidth: 2, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 8 }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||||
|
„{p.data.quote}“
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Values grid */}
|
||||||
|
<View style={{ marginTop: 20 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.values}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
{companyInfo.values.map((v, i) => (
|
||||||
|
<View key={i} style={{ width: '46%', marginBottom: 4 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<View style={{ width: 20, height: 20, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.5, paddingLeft: 28 }}>{v.description}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// TOC PAGE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const TocPage: React.FC<{
|
||||||
|
products: BrochureProduct[];
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
logoBlack?: string | Buffer;
|
||||||
|
productStartPage: number;
|
||||||
|
}> = ({ products, locale, logoBlack, productStartPage }) => {
|
||||||
|
const l = labels(locale);
|
||||||
|
|
||||||
|
// Group products by their first category
|
||||||
|
const categories: Array<{ name: string; products: Array<BrochureProduct & { startingPage: number }> }> = [];
|
||||||
|
let currentPageNum = productStartPage;
|
||||||
|
for (const p of products) {
|
||||||
|
const catName = p.categories[0]?.name || l.other;
|
||||||
|
let category = categories.find(c => c.name === catName);
|
||||||
|
if (!category) {
|
||||||
|
category = { name: catName, products: [] };
|
||||||
|
categories.push(category);
|
||||||
|
}
|
||||||
|
category.products.push({ ...p, startingPage: currentPageNum });
|
||||||
|
currentPageNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
|
<Header logo={logoBlack} right={l.overview} />
|
||||||
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
|
<View style={{ paddingTop: BODY_TOP + 40 }}>
|
||||||
|
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
|
||||||
|
{l.catalog}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{categories.map((cat, i) => (
|
||||||
|
<View key={i} style={{ marginBottom: 16 }} minPresenceAhead={40}>
|
||||||
|
{cat.name && (
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={{ flexDirection: 'column', gap: 6 }}>
|
||||||
|
{cat.products.map((p, j) => (
|
||||||
|
<View key={j} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
|
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{p.name}</Text>
|
||||||
|
<View style={{ flex: 1, borderBottomWidth: 1, borderBottomColor: C.gray200, borderBottomStyle: 'dotted', marginHorizontal: 8, marginBottom: 3 }} />
|
||||||
|
<Text style={{ fontSize: 9, color: C.gray600 }}>{(p.startingPage || 0).toString().padStart(2, '0')}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PRODUCT PAGES
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const ProductPage: React.FC<{
|
||||||
|
product: BrochureProduct;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
logoBlack?: string | Buffer;
|
||||||
|
}> = ({ product, locale, logoBlack }) => {
|
||||||
|
const l = labels(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
|
<Header logo={logoBlack} right="KLZ Cables" />
|
||||||
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
|
{/* Product image block reduced strictly to 110pt high */}
|
||||||
|
{product.featuredImage && (
|
||||||
|
<View style={{ height: 110, marginBottom: 20, marginHorizontal: -MARGIN }}>
|
||||||
|
<Image src={product.featuredImage as unknown as Buffer} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels & Name */}
|
||||||
|
{product.categories.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||||
|
{product.categories.map(c => c.name).join(' • ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 16 }}>{product.name}</Text>
|
||||||
|
|
||||||
|
{/* Description — full width */}
|
||||||
|
{product.descriptionHtml && (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 6 }}>{l.application}</Text>
|
||||||
|
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6 }} gap={6}>
|
||||||
|
{product.descriptionHtml}
|
||||||
|
</RichText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technical Data — full-width striped table */}
|
||||||
|
{product.attributes && product.attributes.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
|
||||||
|
|
||||||
|
{/* Table header */}
|
||||||
|
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 5, paddingHorizontal: 10, marginBottom: 2 }}>
|
||||||
|
<View style={{ width: '50%' }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.value}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{product.attributes.map((attr, i) => (
|
||||||
|
<View key={i} style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingVertical: 5, paddingHorizontal: 10,
|
||||||
|
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
||||||
|
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||||
|
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||||
|
}}>
|
||||||
|
<View style={{ width: '50%', paddingRight: 12 }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Codes — horizontal row at bottom */}
|
||||||
|
{(product.qrWebsite || product.qrDatasheet) && (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 24, marginTop: 8 }}>
|
||||||
|
{product.qrWebsite && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||||
|
<Image src={product.qrWebsite} style={{ width: 36, height: 36 }} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrWeb}</Text>
|
||||||
|
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{product.qrDatasheet && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||||
|
<Image src={product.qrDatasheet} style={{ width: 36, height: 36 }} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrPdf}</Text>
|
||||||
|
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// BACK COVER
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const BackCover: React.FC<{
|
||||||
|
companyInfo: BrochureProps['companyInfo'];
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
logoWhite?: string | Buffer;
|
||||||
|
image?: string | Buffer;
|
||||||
|
}> = ({ companyInfo, locale, logoWhite, image }) => {
|
||||||
|
const l = labels(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||||
|
{/* Background */}
|
||||||
|
{imgValid(image) && (
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||||
|
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.92 }} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!imgValid(image) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||||
|
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: MARGIN }}>
|
||||||
|
{imgValid(logoWhite) ? (
|
||||||
|
<Image src={logoWhite!} style={{ width: 160, marginBottom: 40 }} />
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 700, color: C.white, letterSpacing: 3, textTransform: 'uppercase', marginBottom: 40 }}>KLZ CABLES</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 40 }} />
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.contact}</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: C.white, lineHeight: 1.8, textAlign: 'center', marginBottom: 20 }}>{companyInfo.address}</Text>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 12, color: C.white, marginBottom: 4 }}>{companyInfo.phone}</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: C.gray300, marginBottom: 24 }}>{companyInfo.email}</Text>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: 700, color: C.green }}>{companyInfo.website}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ position: 'absolute', bottom: 28, left: MARGIN, right: MARGIN, alignItems: 'center' }} fixed>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// DOCUMENT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||||
|
products, locale, companyInfo, introContent,
|
||||||
|
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||||
|
}) => {
|
||||||
|
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
|
||||||
|
const numInfoPages = 1 + (marketingSections?.length || 0);
|
||||||
|
const productStartPage = 1 + numInfoPages + 1;
|
||||||
|
|
||||||
|
// Image assignment — each page gets a UNIQUE image, never repeating
|
||||||
|
// galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover
|
||||||
|
// TOC intentionally gets NO image (clean list page)
|
||||||
|
const totalGallery = galleryImages?.length || 0;
|
||||||
|
const backCoverImgIdx = totalGallery - 1;
|
||||||
|
|
||||||
|
// Section themes: alternate light/dark
|
||||||
|
const sectionThemes: Array<'light' | 'dark'> = [];
|
||||||
|
// imagePosition: alternate between top and bottom-half for variety
|
||||||
|
const imagePositions: Array<'top' | 'bottom-half'> = [];
|
||||||
|
if (marketingSections) {
|
||||||
|
for (let i = 0; i < marketingSections.length; i++) {
|
||||||
|
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
|
||||||
|
imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||||
|
|
||||||
|
{/* About page — image[1] */}
|
||||||
|
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} directorPhotos={directorPhotos} />
|
||||||
|
|
||||||
|
{/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */}
|
||||||
|
{marketingSections?.map((section, i) => (
|
||||||
|
<InfoPage
|
||||||
|
key={`info-${i}`}
|
||||||
|
section={section}
|
||||||
|
image={galleryImages?.[i + 2]}
|
||||||
|
logoBlack={logoBlack}
|
||||||
|
logoWhite={logoWhite}
|
||||||
|
dark={sectionThemes[i] === 'dark'}
|
||||||
|
imagePosition={imagePositions[i]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* TOC — no decorative image, clean list */}
|
||||||
|
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} />
|
||||||
|
|
||||||
|
{/* Products — each on its own page */}
|
||||||
|
{products.map(p => (
|
||||||
|
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Back cover — last gallery image */}
|
||||||
|
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
+97
-94
@@ -1,13 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
Document,
|
|
||||||
Page,
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Register fonts (using system fonts for now, can be customized)
|
||||||
Font.register({
|
Font.register({
|
||||||
@@ -18,27 +10,43 @@ Font.register({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
// ─── Brand Tokens (matching brochure) ────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
navy: '#001a4d',
|
||||||
|
navyDeep: '#000d26',
|
||||||
|
green: '#4da612',
|
||||||
|
greenLight: '#e8f5d8',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
|
gray400: '#9ca3af',
|
||||||
|
gray600: '#4b5563',
|
||||||
|
gray900: '#111827',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARGIN = 56;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
color: '#111827', // Text Primary
|
color: C.gray900,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 100,
|
paddingBottom: 80,
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hero-style header
|
// Hero-style header
|
||||||
hero: {
|
hero: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
paddingHorizontal: 72,
|
paddingHorizontal: MARGIN,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
borderBottomColor: '#e5e7eb',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
logoText: {
|
logoText: {
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
letterSpacing: 1,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
|
|
||||||
docTitle: {
|
docTitle: {
|
||||||
fontSize: 10,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#001a4d',
|
color: C.green,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
|
|||||||
height: 120,
|
height: 120,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 8,
|
borderRadius: 4,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e7eb',
|
borderColor: C.gray200,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
|
|||||||
productName: {
|
productName: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
productMeta: {
|
productMeta: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
noImage: {
|
noImage: {
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
color: '#9ca3af',
|
color: C.gray400,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Content Area
|
// Content Area
|
||||||
content: {
|
content: {
|
||||||
paddingHorizontal: 72,
|
paddingHorizontal: MARGIN,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Content sections
|
// Content sections
|
||||||
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26', // Primary Dark
|
color: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.2,
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
sectionAccent: {
|
sectionAccent: {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 2,
|
||||||
backgroundColor: '#82ed20', // Accent Green
|
backgroundColor: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
description: {
|
description: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
color: '#4b5563', // Text Secondary
|
color: C.gray600,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Technical data table
|
// Technical data table
|
||||||
specsTable: {
|
specsTable: {
|
||||||
marginTop: 8,
|
marginTop: 4,
|
||||||
border: '1px solid #e5e7eb',
|
borderWidth: 0,
|
||||||
borderRadius: 8,
|
borderRadius: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableRow: {
|
specsTableRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 0.5,
|
||||||
borderBottomColor: '#e5e7eb',
|
borderBottomColor: C.gray200,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableRowLast: {
|
specsTableRowLast: {
|
||||||
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
specsTableLabelCell: {
|
specsTableLabelCell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 4,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: C.offWhite,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 0.5,
|
||||||
borderRightColor: '#e5e7eb',
|
borderRightColor: C.gray200,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableValueCell: {
|
specsTableValueCell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 4,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableLabelText: {
|
specsTableLabelText: {
|
||||||
fontSize: 9,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableValueText: {
|
specsTableValueText: {
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
color: '#111827',
|
color: C.gray900,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
categories: {
|
categories: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryTag: {
|
categoryTag: {
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: C.offWhite,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 6,
|
paddingVertical: 4,
|
||||||
border: '1px solid #e5e7eb',
|
borderWidth: 0.5,
|
||||||
borderRadius: 100,
|
borderColor: C.gray200,
|
||||||
|
borderRadius: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryText: {
|
categoryText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Footer
|
// Footer — matches brochure style
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 40,
|
bottom: 28,
|
||||||
left: 72,
|
left: MARGIN,
|
||||||
right: 72,
|
right: MARGIN,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: 24,
|
paddingTop: 12,
|
||||||
borderTop: '1px solid #e5e7eb',
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: C.green,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#9ca3af',
|
color: C.gray400,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 0.8,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerBrand: {
|
footerBrand: {
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
return labels[locale];
|
return labels[locale];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||||
product,
|
|
||||||
locale,
|
|
||||||
}) => {
|
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View>
|
<View>
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.docTitle}>
|
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||||
{labels.productDatasheet}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.productRow}>
|
<View style={styles.productRow}>
|
||||||
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View style={styles.categories}>
|
<View style={styles.categories}>
|
||||||
{product.categories.map((cat, index) => (
|
{product.categories.map((cat, index) => (
|
||||||
<Text key={index} style={styles.productMeta}>
|
<Text key={index} style={styles.productMeta}>
|
||||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
{cat.name}
|
||||||
|
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image
|
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||||
src={product.featuredImage}
|
|
||||||
style={styles.heroImage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>
|
||||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
{stripHtml(
|
||||||
|
product.applicationHtml ||
|
||||||
|
product.shortDescriptionHtml ||
|
||||||
|
product.descriptionHtml,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.specsTableRow,
|
styles.specsTableRow,
|
||||||
index === product.attributes.length - 1 &&
|
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||||
styles.specsTableRowLast,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.specsTableLabelCell}>
|
<View style={styles.specsTableLabelCell}>
|
||||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.specsTableValueCell}>
|
<View style={styles.specsTableValueCell}>
|
||||||
<Text style={styles.specsTableValueText}>
|
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||||
{attr.options.join(', ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+8
-8
@@ -1,6 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
import { mapSlugToFileSlug } from './slugs';
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface ProductFrontmatter {
|
export interface ProductFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,6 +18,7 @@ export interface ProductData {
|
|||||||
slug: string;
|
slug: string;
|
||||||
frontmatter: ProductFrontmatter;
|
frontmatter: ProductFrontmatter;
|
||||||
content: any; // Lexical AST from Payload
|
content: any; // Lexical AST from Payload
|
||||||
|
application?: any; // Lexical AST for Application field
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductMetadata(
|
export async function getProductMetadata(
|
||||||
@@ -26,13 +28,12 @@ export async function getProductMetadata(
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -70,13 +71,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -114,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: doc.content,
|
content: doc.content,
|
||||||
|
application: doc.application,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +128,10 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
pagination: false,
|
pagination: false,
|
||||||
@@ -157,11 +157,10 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
images: true,
|
images: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -198,6 +197,7 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: null,
|
content: null,
|
||||||
|
application: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 0.1, // Default to 10% sampling
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
|
|||||||
|
|
||||||
export type GlitchtipErrorReportingServiceOptions = {
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
dsn?: string;
|
||||||
|
tracesSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
if (!this.sentryPromise) {
|
if (!this.sentryPromise) {
|
||||||
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||||
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
if (typeof window !== 'undefined' && this.options.enabled) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://public@errors.infra.mintel.me/1',
|
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
|
||||||
tunnel: '/errors/api/relay',
|
tunnel: '/errors/api/relay',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Utility for formatting technical data values.
|
||||||
|
* Handles long lists of standards and simplifies repetitive strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FormattedTechnicalValue {
|
||||||
|
original: string;
|
||||||
|
isList: boolean;
|
||||||
|
parts: string[];
|
||||||
|
displayValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a technical value string.
|
||||||
|
* Detects if it's a list (separated by / or ,) and tries to clean it up.
|
||||||
|
*/
|
||||||
|
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
|
||||||
|
if (!value) {
|
||||||
|
return { original: '', isList: false, parts: [], displayValue: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(value).trim();
|
||||||
|
|
||||||
|
// Detect list separators
|
||||||
|
let parts: string[] = [];
|
||||||
|
if (str.includes(' / ')) {
|
||||||
|
parts = str.split(' / ').map(p => p.trim());
|
||||||
|
} else if (str.includes(' /')) {
|
||||||
|
parts = str.split(' /').map(p => p.trim());
|
||||||
|
} else if (str.includes('/ ')) {
|
||||||
|
parts = str.split('/ ').map(p => p.trim());
|
||||||
|
} else if (str.split('/').length > 2) {
|
||||||
|
// Check if it's actually many standards separated by / without spaces
|
||||||
|
// e.g. EN123/EN456/EN789
|
||||||
|
const split = str.split('/');
|
||||||
|
if (split.length > 3) {
|
||||||
|
parts = split.map(p => p.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no parts found yet, try comma
|
||||||
|
if (parts.length === 0 && str.includes(', ')) {
|
||||||
|
parts = str.split(', ').map(p => p.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty parts
|
||||||
|
parts = parts.filter(Boolean);
|
||||||
|
|
||||||
|
// If we have parts, let's see if we can simplify them
|
||||||
|
if (parts.length > 2) {
|
||||||
|
// Find common prefix to condense repetitive standards
|
||||||
|
let commonPrefix = '';
|
||||||
|
const first = parts[0];
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
let i = 0;
|
||||||
|
while (i < first.length && first.charAt(i) === last.charAt(i)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
commonPrefix = first.substring(0, i);
|
||||||
|
|
||||||
|
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
|
||||||
|
if (commonPrefix.length > 4) {
|
||||||
|
// Trim trailing spaces/dashes before comparing words
|
||||||
|
const basePrefix = commonPrefix.trim();
|
||||||
|
const suffixParts: string[] = [];
|
||||||
|
|
||||||
|
for (let idx = 0; idx < parts.length; idx++) {
|
||||||
|
if (idx === 0) {
|
||||||
|
suffixParts.push(parts[idx]);
|
||||||
|
} else {
|
||||||
|
const suffix = parts[idx].substring(commonPrefix.length).trim();
|
||||||
|
if (suffix) {
|
||||||
|
suffixParts.push(suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
|
||||||
|
// Wait, returning a single string might still wrap badly.
|
||||||
|
// Instead, we return them as chunks or just a condensed string.
|
||||||
|
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: str,
|
||||||
|
isList: false, // Turn off badge rendering to use text block instead
|
||||||
|
parts: [condensedString],
|
||||||
|
displayValue: condensedString
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no common prefix, return as list so UI can render badges
|
||||||
|
return {
|
||||||
|
original: str,
|
||||||
|
isList: true,
|
||||||
|
parts,
|
||||||
|
displayValue: parts.join(', ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: str,
|
||||||
|
isList: false,
|
||||||
|
parts: [str],
|
||||||
|
displayValue: str
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -226,6 +226,10 @@
|
|||||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||||
"downloadDatasheet": "Datenblatt herunterladen",
|
"downloadDatasheet": "Datenblatt herunterladen",
|
||||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
||||||
|
"downloadExcel": "Excel herunterladen",
|
||||||
|
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
|
||||||
|
"downloadBrochure": "Produktbroschüre",
|
||||||
|
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
|
||||||
"form": {
|
"form": {
|
||||||
"contactInfo": "Kontaktinformationen",
|
"contactInfo": "Kontaktinformationen",
|
||||||
"projectDetails": "Projektdetails",
|
"projectDetails": "Projektdetails",
|
||||||
@@ -395,5 +399,21 @@
|
|||||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||||
"cta": "Zurück zur Sicherheit"
|
"cta": "Zurück zur Sicherheit"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Brochure": {
|
||||||
|
"title": "Produktkatalog",
|
||||||
|
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
|
||||||
|
"emailPlaceholder": "ihre@email.de",
|
||||||
|
"emailLabel": "E-Mail-Adresse",
|
||||||
|
"submit": "Broschüre erhalten",
|
||||||
|
"submitting": "Wird gesendet...",
|
||||||
|
"successTitle": "Ihre Broschüre ist bereit!",
|
||||||
|
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
|
||||||
|
"download": "Broschüre herunterladen",
|
||||||
|
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||||
|
"close": "Schließen",
|
||||||
|
"ctaTitle": "Kompletter Produktkatalog",
|
||||||
|
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
|
||||||
|
"ctaButton": "Kostenlose Broschüre erhalten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +226,10 @@
|
|||||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||||
"downloadDatasheet": "Download Datasheet",
|
"downloadDatasheet": "Download Datasheet",
|
||||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
||||||
|
"downloadExcel": "Download Excel",
|
||||||
|
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
|
||||||
|
"downloadBrochure": "Product Brochure",
|
||||||
|
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
|
||||||
"form": {
|
"form": {
|
||||||
"contactInfo": "Contact Information",
|
"contactInfo": "Contact Information",
|
||||||
"projectDetails": "Project Details",
|
"projectDetails": "Project Details",
|
||||||
@@ -395,5 +399,21 @@
|
|||||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||||
"cta": "Back to Safety"
|
"cta": "Back to Safety"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Brochure": {
|
||||||
|
"title": "Product Catalog",
|
||||||
|
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"emailLabel": "Email Address",
|
||||||
|
"submit": "Get Brochure",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"successTitle": "Your brochure is ready!",
|
||||||
|
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
|
||||||
|
"download": "Download Brochure",
|
||||||
|
"privacyNote": "By submitting you agree to our privacy policy.",
|
||||||
|
"close": "Close",
|
||||||
|
"ctaTitle": "Complete Product Catalog",
|
||||||
|
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
|
||||||
|
"ctaButton": "Get Free Brochure"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,8 +45,10 @@ export default async function middleware(request: NextRequest) {
|
|||||||
if (internalHosts.includes(urlObj.hostname)) {
|
if (internalHosts.includes(urlObj.hostname)) {
|
||||||
const proto = headers.get('x-forwarded-proto') || 'https';
|
const proto = headers.get('x-forwarded-proto') || 'https';
|
||||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||||
const hostHeader =
|
const fallbackHost = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
? new URL(process.env.NEXT_PUBLIC_BASE_URL).host
|
||||||
|
: 'klz-cables.com';
|
||||||
|
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ export default async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|xlsx|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||||
'/(de|en)/:path*',
|
'/(de|en)/:path*',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
Vendored
+1
-1
@@ -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.
|
||||||
|
|||||||
+50
-3
@@ -13,11 +13,11 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
cpus: 1,
|
cpus: 3,
|
||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
memoryBasedWorkersCount: true,
|
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
swcMinify: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
@@ -25,6 +25,18 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isProd ? { output: 'standalone' } : {}),
|
...(isProd ? { output: 'standalone' } : {}),
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:locale/datasheets/:path*',
|
||||||
|
destination: '/datasheets/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/:locale/brochure/:path*',
|
||||||
|
destination: '/brochure/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||||
@@ -79,7 +91,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Strict-Transport-Security',
|
key: 'Strict-Transport-Security',
|
||||||
value: 'max-age=63072000; includeSubDomains; preload',
|
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -393,7 +405,9 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
qualities: [75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/webp'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
@@ -403,6 +417,14 @@ const nextConfig = {
|
|||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.klz-cables.com',
|
hostname: '*.klz-cables.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'klz-cables.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '*.klz-cables.com',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
hostname: 'klz.localhost',
|
hostname: 'klz.localhost',
|
||||||
@@ -428,6 +450,31 @@ const nextConfig = {
|
|||||||
source: '/de/kontakt',
|
source: '/de/kontakt',
|
||||||
destination: '/de/contact',
|
destination: '/de/contact',
|
||||||
},
|
},
|
||||||
|
// Safety rewrites for English locale using German slugs (legacy or content errors)
|
||||||
|
{
|
||||||
|
source: '/en/produkte',
|
||||||
|
destination: '/en/products',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/produkte/:path*',
|
||||||
|
destination: '/en/products/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/kontakt',
|
||||||
|
destination: '/en/contact',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/impressum',
|
||||||
|
destination: '/en/legal-notice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/datenschutz',
|
||||||
|
destination: '/en/privacy-policy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/agbs',
|
||||||
|
destination: '/en/terms',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
afterFiles: [],
|
afterFiles: [],
|
||||||
fallback: [],
|
fallback: [],
|
||||||
|
|||||||
+12
-7
@@ -4,10 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/mail": "1.8.3",
|
"@mintel/mail": "^1.8.21",
|
||||||
"@mintel/next-config": "1.8.3",
|
"@mintel/next-config": "^1.8.21",
|
||||||
"@mintel/next-feedback": "1.8.10",
|
"@mintel/next-feedback": "^1.8.21",
|
||||||
"@mintel/next-utils": "^1.7.15",
|
"@mintel/next-utils": "^1.8.21",
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.8.3",
|
"@mintel/eslint-config": "1.8.21",
|
||||||
"@mintel/tsconfig": "1.8.3",
|
"@mintel/tsconfig": "^1.8.21",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -110,8 +111,12 @@
|
|||||||
"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",
|
||||||
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
||||||
|
"check:forms": "tsx ./scripts/check-forms.ts",
|
||||||
|
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||||
|
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||||
|
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||||
"cms:migrate": "payload migrate",
|
"cms:migrate": "payload migrate",
|
||||||
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||||
@@ -136,7 +141,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.0.2",
|
"version": "2.2.12",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ export interface FormSubmission {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
type: 'contact' | 'product_quote';
|
type: 'contact' | 'product_quote' | 'brochure_download';
|
||||||
/**
|
/**
|
||||||
* The specific KLZ product the user requested a quote for.
|
* The specific KLZ product the user requested a quote for.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
-6
@@ -45,9 +45,7 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
titleSuffix: ' – KLZ Cables',
|
titleSuffix: ' – KLZ Cables',
|
||||||
icons: [
|
icons: [{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }],
|
||||||
{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
@@ -80,18 +78,25 @@ export default buildConfig({
|
|||||||
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
email: nodemailerAdapter({
|
email: process.env.MAIL_HOST
|
||||||
defaultFromAddress: process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
? nodemailerAdapter({
|
||||||
|
defaultFromAddress:
|
||||||
|
process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
||||||
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
||||||
transportOptions: {
|
transportOptions: {
|
||||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||||
port: Number(process.env.MAIL_PORT) || 587,
|
port: Number(process.env.MAIL_PORT) || 587,
|
||||||
|
...(process.env.MAIL_USERNAME
|
||||||
|
? {
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.MAIL_USERNAME,
|
user: process.env.MAIL_USERNAME,
|
||||||
pass: process.env.MAIL_PASSWORD,
|
pass: process.env.MAIL_PASSWORD,
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
|
: undefined,
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [],
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+250
-120
@@ -13,17 +13,17 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mintel/mail':
|
'@mintel/mail':
|
||||||
specifier: 1.8.3
|
specifier: ^1.8.21
|
||||||
version: 1.8.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.8.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@mintel/next-config':
|
'@mintel/next-config':
|
||||||
specifier: 1.8.3
|
specifier: ^1.8.21
|
||||||
version: 1.8.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.25.12))
|
version: 1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.25.12))
|
||||||
'@mintel/next-feedback':
|
'@mintel/next-feedback':
|
||||||
specifier: 1.8.10
|
specifier: ^1.8.21
|
||||||
version: 1.8.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
version: 1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
||||||
'@mintel/next-utils':
|
'@mintel/next-utils':
|
||||||
specifier: ^1.7.15
|
specifier: ^1.8.21
|
||||||
version: 1.7.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)
|
version: 1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)
|
||||||
'@payloadcms/db-postgres':
|
'@payloadcms/db-postgres':
|
||||||
specifier: ^3.77.0
|
specifier: ^3.77.0
|
||||||
version: 3.77.0(@opentelemetry/api@1.9.0)(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))
|
version: 3.77.0(@opentelemetry/api@1.9.0)(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))
|
||||||
@@ -155,11 +155,11 @@ importers:
|
|||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
'@mintel/eslint-config':
|
'@mintel/eslint-config':
|
||||||
specifier: 1.8.3
|
specifier: 1.8.21
|
||||||
version: 1.8.3(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
version: 1.8.21(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@mintel/tsconfig':
|
'@mintel/tsconfig':
|
||||||
specifier: 1.8.3
|
specifier: ^1.8.21
|
||||||
version: 1.8.3
|
version: 1.8.21
|
||||||
'@next/bundle-analyzer':
|
'@next/bundle-analyzer':
|
||||||
specifier: ^16.1.6
|
specifier: ^16.1.6
|
||||||
version: 16.1.6
|
version: 16.1.6
|
||||||
@@ -190,6 +190,9 @@ importers:
|
|||||||
'@types/sharp':
|
'@types/sharp':
|
||||||
specifier: ^0.31.1
|
specifier: ^0.31.1
|
||||||
version: 0.31.1
|
version: 0.31.1
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.183.1
|
||||||
|
version: 0.183.1
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 5.1.4(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
@@ -721,6 +724,9 @@ packages:
|
|||||||
'@date-fns/tz@1.2.0':
|
'@date-fns/tz@1.2.0':
|
||||||
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
'@directus/sdk@21.1.0':
|
'@directus/sdk@21.1.0':
|
||||||
resolution: {integrity: sha512-Ig8zZAQDbc7QMIM54N+x71C04lni9MN9yalNAezjDjFdNknTJzupDY7V5cb+kOJL8GsqDE9Bg8xq8xCmkDVs5A==}
|
resolution: {integrity: sha512-Ig8zZAQDbc7QMIM54N+x71C04lni9MN9yalNAezjDjFdNknTJzupDY7V5cb+kOJL8GsqDE9Bg8xq8xCmkDVs5A==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
@@ -1293,10 +1299,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
|
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@eslint/eslintrc@3.3.4':
|
||||||
|
resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==}
|
||||||
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/js@9.39.2':
|
'@eslint/js@9.39.2':
|
||||||
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
|
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@eslint/js@9.39.3':
|
||||||
|
resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==}
|
||||||
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.7':
|
'@eslint/object-schema@2.1.7':
|
||||||
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1677,29 +1691,29 @@ packages:
|
|||||||
'@medv/finder@4.0.2':
|
'@medv/finder@4.0.2':
|
||||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||||
|
|
||||||
'@mintel/eslint-config@1.8.3':
|
'@mintel/eslint-config@1.8.21':
|
||||||
resolution: {integrity: sha512-Alr0ofai74qhnrhZy2mpNSR4gcqD4DTQLHcHX3pZPpsqu1QfNjhHofU7dyHDx5/SpXoMXsI4hSaLpytKEMNHLg==}
|
resolution: {integrity: sha512-GH5tm1y89AhD+Lxf95BGCOdy7Nv1OPNLWrUpaTR6jsuKfH2dm9fU66LF7sDH5THmrkfAZ8zSzHJsKPjintv3IA==}
|
||||||
|
|
||||||
'@mintel/mail@1.8.3':
|
'@mintel/mail@1.8.21':
|
||||||
resolution: {integrity: sha512-y6MD0Rkn66D6gbMH1/6HNpneCJHUkDxtW7mYnq/ftRZf27ZDuhYqJaQVfXTCXS80oaWTzygc33Lpqz4UpdavQw==}
|
resolution: {integrity: sha512-leZV9gINmxD4eVJ3Ij9KdrQoyib67NVHgL/93J7KcWSUWKbr2HVuKUBpiWImeeEZn3JO0f7JwRbVUzXPBRVeQA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.0.0
|
react: ^19.0.0
|
||||||
react-dom: ^19.0.0
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@mintel/next-config@1.8.3':
|
'@mintel/next-config@1.8.21':
|
||||||
resolution: {integrity: sha512-2t0ql4FCOqzLc4ImE3D39us5YHeKq9ZHjmWyiz9cwtJm89zGSvB/mzCja2ydrYtTEfLwxpiVKzXrVuSKLjFlow==}
|
resolution: {integrity: sha512-K4jb9Glf84a212BRZ/zmOUueBphmsikvStFCuDc5lxyFT+Hkj4w8ChmtI7gaUxHMrftooduGPXJ1+NFpKkvc/Q==}
|
||||||
|
|
||||||
'@mintel/next-feedback@1.8.10':
|
'@mintel/next-feedback@1.8.21':
|
||||||
resolution: {integrity: sha512-GgCkcgvAibOXli0bfx2/PLJgzPC0sTM67Wddef2eDCiV2Lgy4hZjX9P+PSKP5dwPGooyk29muOtm0HKJlkAXqg==}
|
resolution: {integrity: sha512-n2KzGDbOvAskuzjbt8h5EOMSEnISxHrsXxJwDdMxCXEgmzfJSvWpP2mAqb684dimOwo1UWHE6DMSAFc1FXeYwg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.0.0
|
react: ^19.0.0
|
||||||
react-dom: ^19.0.0
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@mintel/next-utils@1.7.15':
|
'@mintel/next-utils@1.8.21':
|
||||||
resolution: {integrity: sha512-CqSe3eHamq9zLs+AJxGOPypTLchw/oZ3JcLkor007PcUDMTv/Lspfv5oCaXK2s0FeIOJaa2QwSGPDI1h5/3ZVw==}
|
resolution: {integrity: sha512-sr0yDtySGou+3DNvrqY6HWSHCiVIc8nnoRbckyPMSE21AGxk2aJineXGy9BO9tulSBdhStm2SgdC7McMFTszug==}
|
||||||
|
|
||||||
'@mintel/tsconfig@1.8.3':
|
'@mintel/tsconfig@1.8.21':
|
||||||
resolution: {integrity: sha512-IOtxrTZfPZs4XU4Q068dSUtETFb2drf43mgmaF31j+PtltfPkC7pdJcEjXDg365bZ5EUrgTlt9pYlgGmfrCEVQ==}
|
resolution: {integrity: sha512-ePBfBZiijyXKOS6nLIyxkg7QDZEEC1TugzNhmvwwpc0Yh7BmVHyNpvjg6zKsoGj2rok+9Kc8mLH1WihQIs8SKg==}
|
||||||
|
|
||||||
'@monaco-editor/loader@1.7.0':
|
'@monaco-editor/loader@1.7.0':
|
||||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||||
@@ -3039,6 +3053,9 @@ packages:
|
|||||||
'@tootallnate/quickjs-emscripten@0.23.0':
|
'@tootallnate/quickjs-emscripten@0.23.0':
|
||||||
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3':
|
||||||
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -3152,6 +3169,9 @@ packages:
|
|||||||
'@types/node@22.19.11':
|
'@types/node@22.19.11':
|
||||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||||
|
|
||||||
|
'@types/node@22.19.13':
|
||||||
|
resolution: {integrity: sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==}
|
||||||
|
|
||||||
'@types/nodemailer@7.0.9':
|
'@types/nodemailer@7.0.9':
|
||||||
resolution: {integrity: sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==}
|
resolution: {integrity: sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==}
|
||||||
|
|
||||||
@@ -3187,9 +3207,15 @@ packages:
|
|||||||
'@types/sharp@0.31.1':
|
'@types/sharp@0.31.1':
|
||||||
resolution: {integrity: sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==}
|
resolution: {integrity: sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==}
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
'@types/tedious@4.0.14':
|
'@types/tedious@4.0.14':
|
||||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||||
|
|
||||||
|
'@types/three@0.183.1':
|
||||||
|
resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -3205,6 +3231,9 @@ packages:
|
|||||||
'@types/uuid@10.0.0':
|
'@types/uuid@10.0.0':
|
||||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||||
|
|
||||||
|
'@types/webxr@0.5.24':
|
||||||
|
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2':
|
'@types/whatwg-mimetype@3.0.2':
|
||||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
@@ -3214,63 +3243,63 @@ packages:
|
|||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.55.0':
|
'@typescript-eslint/eslint-plugin@8.56.1':
|
||||||
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
|
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@typescript-eslint/parser': ^8.55.0
|
'@typescript-eslint/parser': ^8.56.1
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.55.0':
|
'@typescript-eslint/parser@8.56.1':
|
||||||
resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==}
|
resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/project-service@8.55.0':
|
'@typescript-eslint/project-service@8.56.1':
|
||||||
resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==}
|
resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/scope-manager@8.55.0':
|
'@typescript-eslint/scope-manager@8.56.1':
|
||||||
resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==}
|
resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@typescript-eslint/tsconfig-utils@8.55.0':
|
'@typescript-eslint/tsconfig-utils@8.56.1':
|
||||||
resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==}
|
resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.55.0':
|
'@typescript-eslint/type-utils@8.56.1':
|
||||||
resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==}
|
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/types@8.55.0':
|
'@typescript-eslint/types@8.56.1':
|
||||||
resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==}
|
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@typescript-eslint/typescript-estree@8.55.0':
|
'@typescript-eslint/typescript-estree@8.56.1':
|
||||||
resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==}
|
resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.55.0':
|
'@typescript-eslint/utils@8.56.1':
|
||||||
resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==}
|
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
'@typescript-eslint/visitor-keys@8.55.0':
|
'@typescript-eslint/visitor-keys@8.56.1':
|
||||||
resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==}
|
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
@@ -3453,6 +3482,9 @@ packages:
|
|||||||
'@webassemblyjs/wast-printer@1.14.1':
|
'@webassemblyjs/wast-printer@1.14.1':
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.69':
|
||||||
|
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -3537,6 +3569,9 @@ packages:
|
|||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
|
ajv@6.14.0:
|
||||||
|
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||||
|
|
||||||
ajv@8.17.1:
|
ajv@8.17.1:
|
||||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||||
|
|
||||||
@@ -3715,6 +3750,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
|
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
balanced-match@4.0.4:
|
||||||
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
bare-events@2.8.2:
|
bare-events@2.8.2:
|
||||||
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3804,6 +3843,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
brace-expansion@5.0.4:
|
||||||
|
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4824,6 +4867,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
eslint-visitor-keys@5.0.1:
|
||||||
|
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
eslint@9.39.2:
|
eslint@9.39.2:
|
||||||
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
|
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -6162,6 +6209,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
meshoptimizer@1.0.1:
|
||||||
|
resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==}
|
||||||
|
|
||||||
metaviewport-parser@0.3.0:
|
metaviewport-parser@0.3.0:
|
||||||
resolution: {integrity: sha512-EoYJ8xfjQ6kpe9VbVHvZTZHiOl4HL1Z18CrZ+qahvLXT7ZO4YTC2JMyt5FaUp9JJp6J4Ybb/z7IsCXZt86/QkQ==}
|
resolution: {integrity: sha512-EoYJ8xfjQ6kpe9VbVHvZTZHiOl4HL1Z18CrZ+qahvLXT7ZO4YTC2JMyt5FaUp9JJp6J4Ybb/z7IsCXZt86/QkQ==}
|
||||||
|
|
||||||
@@ -6282,6 +6332,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==}
|
resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
|
minimatch@10.2.4:
|
||||||
|
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
@@ -6421,6 +6475,10 @@ packages:
|
|||||||
node-addon-api@7.1.1:
|
node-addon-api@7.1.1:
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
|
node-exports-info@1.6.0:
|
||||||
|
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -7157,8 +7215,9 @@ packages:
|
|||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
resolve@2.0.0-next.5:
|
resolve@2.0.0-next.6:
|
||||||
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
|
resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
restore-cursor@2.0.0:
|
restore-cursor@2.0.0:
|
||||||
@@ -7887,11 +7946,11 @@ packages:
|
|||||||
typedarray-to-buffer@3.1.5:
|
typedarray-to-buffer@3.1.5:
|
||||||
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
|
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
|
||||||
|
|
||||||
typescript-eslint@8.55.0:
|
typescript-eslint@8.56.1:
|
||||||
resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==}
|
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.0.0'
|
typescript: '>=4.8.4 <6.0.0'
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
@@ -8892,6 +8951,8 @@ snapshots:
|
|||||||
|
|
||||||
'@date-fns/tz@1.2.0': {}
|
'@date-fns/tz@1.2.0': {}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
'@directus/sdk@21.1.0': {}
|
'@directus/sdk@21.1.0': {}
|
||||||
|
|
||||||
'@discoveryjs/json-ext@0.5.7': {}
|
'@discoveryjs/json-ext@0.5.7': {}
|
||||||
@@ -9281,8 +9342,24 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@eslint/eslintrc@3.3.4':
|
||||||
|
dependencies:
|
||||||
|
ajv: 6.14.0
|
||||||
|
debug: 4.4.3
|
||||||
|
espree: 10.4.0
|
||||||
|
globals: 14.0.0
|
||||||
|
ignore: 5.3.2
|
||||||
|
import-fresh: 3.3.1
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
minimatch: 10.2.4
|
||||||
|
strip-json-comments: 3.1.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@eslint/js@9.39.2': {}
|
'@eslint/js@9.39.2': {}
|
||||||
|
|
||||||
|
'@eslint/js@9.39.3': {}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.7': {}
|
'@eslint/object-schema@2.1.7': {}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.4.1':
|
'@eslint/plugin-kit@0.4.1':
|
||||||
@@ -9750,15 +9827,15 @@ snapshots:
|
|||||||
|
|
||||||
'@medv/finder@4.0.2': {}
|
'@medv/finder@4.0.2': {}
|
||||||
|
|
||||||
'@mintel/eslint-config@1.8.3(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@mintel/eslint-config@1.8.21(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/eslintrc': 3.3.3
|
'@eslint/eslintrc': 3.3.4
|
||||||
'@eslint/js': 9.39.2
|
'@eslint/js': 9.39.3
|
||||||
'@next/eslint-plugin-next': 16.1.6
|
'@next/eslint-plugin-next': 16.1.6
|
||||||
eslint-config-next: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
eslint-config-next: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||||
typescript-eslint: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
typescript-eslint: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@typescript-eslint/parser'
|
- '@typescript-eslint/parser'
|
||||||
- eslint
|
- eslint
|
||||||
@@ -9767,13 +9844,13 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@mintel/mail@1.8.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@mintel/mail@1.8.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@react-email/components': 0.0.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
'@react-email/components': 0.0.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
'@mintel/next-config@1.8.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.25.12))':
|
'@mintel/next-config@1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.25.12))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sentry/nextjs': 10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.0(esbuild@0.25.12))
|
'@sentry/nextjs': 10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.0(esbuild@0.25.12))
|
||||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
||||||
@@ -9796,7 +9873,7 @@ snapshots:
|
|||||||
- typescript
|
- typescript
|
||||||
- webpack
|
- webpack
|
||||||
|
|
||||||
'@mintel/next-feedback@1.8.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)':
|
'@mintel/next-feedback@1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@directus/sdk': 21.1.0
|
'@directus/sdk': 21.1.0
|
||||||
'@medv/finder': 4.0.2
|
'@medv/finder': 4.0.2
|
||||||
@@ -9817,7 +9894,7 @@ snapshots:
|
|||||||
- babel-plugin-react-compiler
|
- babel-plugin-react-compiler
|
||||||
- sass
|
- sass
|
||||||
|
|
||||||
'@mintel/next-utils@1.7.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)':
|
'@mintel/next-utils@1.8.21(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@directus/sdk': 21.1.0
|
'@directus/sdk': 21.1.0
|
||||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)
|
||||||
@@ -9835,7 +9912,7 @@ snapshots:
|
|||||||
- sass
|
- sass
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@mintel/tsconfig@1.8.3': {}
|
'@mintel/tsconfig@1.8.21': {}
|
||||||
|
|
||||||
'@monaco-editor/loader@1.7.0':
|
'@monaco-editor/loader@1.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11352,6 +11429,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -11484,6 +11563,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@22.19.13':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/nodemailer@7.0.9':
|
'@types/nodemailer@7.0.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.10
|
'@types/node': 22.19.10
|
||||||
@@ -11532,10 +11615,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.10
|
'@types/node': 22.19.10
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4': {}
|
||||||
|
|
||||||
'@types/tedious@4.0.14':
|
'@types/tedious@4.0.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.10
|
'@types/node': 22.19.10
|
||||||
|
|
||||||
|
'@types/three@0.183.1':
|
||||||
|
dependencies:
|
||||||
|
'@dimforge/rapier3d-compat': 0.12.0
|
||||||
|
'@tweenjs/tween.js': 23.1.3
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
'@webgpu/types': 0.1.69
|
||||||
|
fflate: 0.8.2
|
||||||
|
meshoptimizer: 1.0.1
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -11547,6 +11642,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/uuid@10.0.0': {}
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
|
'@types/webxr@0.5.24': {}
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2': {}
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
@@ -11558,14 +11655,14 @@ snapshots:
|
|||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.11
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/scope-manager': 8.55.0
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/type-utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.55.0
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
@@ -11574,41 +11671,41 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.55.0
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.55.0
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/project-service@8.55.0(typescript@5.9.3)':
|
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/scope-manager@8.55.0':
|
'@typescript-eslint/scope-manager@8.56.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/visitor-keys': 8.55.0
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
|
|
||||||
'@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)':
|
'@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/type-utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
@@ -11616,16 +11713,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/types@8.55.0': {}
|
'@typescript-eslint/types@8.56.1': {}
|
||||||
|
|
||||||
'@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)':
|
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/project-service': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/visitor-keys': 8.55.0
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
minimatch: 10.2.2
|
minimatch: 10.2.4
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
@@ -11633,21 +11730,21 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||||
'@typescript-eslint/scope-manager': 8.55.0
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/visitor-keys@8.55.0':
|
'@typescript-eslint/visitor-keys@8.56.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.55.0
|
'@typescript-eslint/types': 8.56.1
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -11846,6 +11943,8 @@ snapshots:
|
|||||||
'@webassemblyjs/ast': 1.14.1
|
'@webassemblyjs/ast': 1.14.1
|
||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.69': {}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -11913,6 +12012,13 @@ snapshots:
|
|||||||
json-schema-traverse: 0.4.1
|
json-schema-traverse: 0.4.1
|
||||||
uri-js: 4.4.1
|
uri-js: 4.4.1
|
||||||
|
|
||||||
|
ajv@6.14.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-json-stable-stringify: 2.1.0
|
||||||
|
json-schema-traverse: 0.4.1
|
||||||
|
uri-js: 4.4.1
|
||||||
|
|
||||||
ajv@8.17.1:
|
ajv@8.17.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -12103,6 +12209,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.3: {}
|
balanced-match@4.0.3: {}
|
||||||
|
|
||||||
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
bare-events@2.8.2: {}
|
bare-events@2.8.2: {}
|
||||||
|
|
||||||
bare-fs@4.5.3:
|
bare-fs@4.5.3:
|
||||||
@@ -12191,6 +12299,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.3
|
balanced-match: 4.0.3
|
||||||
|
|
||||||
|
brace-expansion@5.0.4:
|
||||||
|
dependencies:
|
||||||
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
@@ -13228,18 +13340,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
source-map: 0.6.1
|
source-map: 0.6.1
|
||||||
|
|
||||||
eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 16.1.6
|
'@next/eslint-plugin-next': 16.1.6
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||||
globals: 16.4.0
|
globals: 16.4.0
|
||||||
typescript-eslint: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
typescript-eslint: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -13267,22 +13379,22 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -13293,11 +13405,11 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
minimatch: 10.2.2
|
minimatch: 10.2.4
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
object.groupby: 1.0.3
|
object.groupby: 1.0.3
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
@@ -13305,7 +13417,7 @@ snapshots:
|
|||||||
string.prototype.trimend: 1.0.9
|
string.prototype.trimend: 1.0.9
|
||||||
tsconfig-paths: 3.15.0
|
tsconfig-paths: 3.15.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- eslint-import-resolver-typescript
|
- eslint-import-resolver-typescript
|
||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
@@ -13325,7 +13437,7 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
jsx-ast-utils: 3.3.5
|
jsx-ast-utils: 3.3.5
|
||||||
language-tags: 1.0.9
|
language-tags: 1.0.9
|
||||||
minimatch: 10.2.2
|
minimatch: 10.2.4
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
safe-regex-test: 1.1.0
|
safe-regex-test: 1.1.0
|
||||||
string.prototype.includes: 2.0.1
|
string.prototype.includes: 2.0.1
|
||||||
@@ -13353,12 +13465,12 @@ snapshots:
|
|||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
jsx-ast-utils: 3.3.5
|
jsx-ast-utils: 3.3.5
|
||||||
minimatch: 10.2.2
|
minimatch: 10.2.4
|
||||||
object.entries: 1.1.9
|
object.entries: 1.1.9
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
prop-types: 15.8.1
|
prop-types: 15.8.1
|
||||||
resolve: 2.0.0-next.5
|
resolve: 2.0.0-next.6
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
string.prototype.matchall: 4.0.12
|
string.prototype.matchall: 4.0.12
|
||||||
string.prototype.repeat: 1.0.0
|
string.prototype.repeat: 1.0.0
|
||||||
@@ -13377,6 +13489,8 @@ snapshots:
|
|||||||
|
|
||||||
eslint-visitor-keys@4.2.1: {}
|
eslint-visitor-keys@4.2.1: {}
|
||||||
|
|
||||||
|
eslint-visitor-keys@5.0.1: {}
|
||||||
|
|
||||||
eslint@9.39.2(jiti@2.6.1):
|
eslint@9.39.2(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||||
@@ -14366,7 +14480,7 @@ snapshots:
|
|||||||
|
|
||||||
jest-worker@27.5.1:
|
jest-worker@27.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.13
|
||||||
merge-stream: 2.0.0
|
merge-stream: 2.0.0
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
@@ -14836,6 +14950,8 @@ snapshots:
|
|||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
|
meshoptimizer@1.0.1: {}
|
||||||
|
|
||||||
metaviewport-parser@0.3.0: {}
|
metaviewport-parser@0.3.0: {}
|
||||||
|
|
||||||
methods@1.1.2: {}
|
methods@1.1.2: {}
|
||||||
@@ -15038,6 +15154,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.2
|
brace-expansion: 5.0.2
|
||||||
|
|
||||||
|
minimatch@10.2.4:
|
||||||
|
dependencies:
|
||||||
|
brace-expansion: 5.0.4
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
minipass@7.1.2: {}
|
minipass@7.1.2: {}
|
||||||
@@ -15160,6 +15280,13 @@ snapshots:
|
|||||||
|
|
||||||
node-addon-api@7.1.1: {}
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
|
node-exports-info@1.6.0:
|
||||||
|
dependencies:
|
||||||
|
array.prototype.flatmap: 1.3.3
|
||||||
|
es-errors: 1.3.0
|
||||||
|
object.entries: 1.1.9
|
||||||
|
semver: 6.3.1
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
@@ -16079,9 +16206,12 @@ snapshots:
|
|||||||
path-parse: 1.0.7
|
path-parse: 1.0.7
|
||||||
supports-preserve-symlinks-flag: 1.0.0
|
supports-preserve-symlinks-flag: 1.0.0
|
||||||
|
|
||||||
resolve@2.0.0-next.5:
|
resolve@2.0.0-next.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
|
node-exports-info: 1.6.0
|
||||||
|
object-keys: 1.1.1
|
||||||
path-parse: 1.0.7
|
path-parse: 1.0.7
|
||||||
supports-preserve-symlinks-flag: 1.0.0
|
supports-preserve-symlinks-flag: 1.0.0
|
||||||
|
|
||||||
@@ -16940,12 +17070,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-typedarray: 1.0.0
|
is-typedarray: 1.0.0
|
||||||
|
|
||||||
typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
typescript-eslint@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user