Compare commits
17 Commits
0be885428d
...
v2.2.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 |
@@ -385,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_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||
DO \\\$\\\$ BEGIN
|
||||
DELETE FROM payload_migrations WHERE batch = -1;
|
||||
INSERT INTO payload_migrations (name, batch)
|
||||
SELECT name, batch FROM (VALUES
|
||||
('20260223_195005_products_collection', 1),
|
||||
('20260223_195151_remove_sku_unique', 2),
|
||||
('20260225_003500_add_pages_collection', 3)
|
||||
) AS v(name, batch)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||
EXCEPTION WHEN undefined_table THEN
|
||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||
END \\\$\\\$;
|
||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||
|
||||
# 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 \"
|
||||
DO \\\$\\\$ BEGIN
|
||||
DELETE FROM payload_migrations WHERE batch = -1;
|
||||
INSERT INTO payload_migrations (name, batch)
|
||||
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||
EXCEPTION WHEN undefined_table THEN
|
||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||
END \\\$\\\$;
|
||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||
fi
|
||||
|
||||
# Restart app to pick up clean migration state
|
||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||
@@ -437,7 +446,9 @@ jobs:
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -474,73 +485,47 @@ jobs:
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
# ── 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
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
- name: 🌐 Full Sitemap HTTP Validation
|
||||
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||
with:
|
||||
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm run check:http
|
||||
- name: 🌐 Locale & Language Switcher Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm run check:locale
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
|
||||
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
||||
- name: 🌐 HTML DOM Validation
|
||||
- name: 📝 E2E Form Submission Test
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:html
|
||||
- name: 🔒 Security Headers Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:security
|
||||
- name: 🔗 Lychee Deep Link Crawl
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:links
|
||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
run: pnpm check:assets
|
||||
- name: ⚡ Lighthouse CI
|
||||
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' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run pagespeed:test
|
||||
- name: ♿ WCAG Audit
|
||||
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' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run check:wcag
|
||||
run: pnpm run check:forms
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
|
||||
17
.gitea/workflows/qa.yml
Normal file
17
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-qa-workflow:
|
||||
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
||||
with:
|
||||
TARGET_URL: 'https://testing.klz-cables.com'
|
||||
PROJECT_NAME: 'klz-2026'
|
||||
secrets:
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
@@ -27,6 +27,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm store prune && \
|
||||
pnpm install --no-frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
|
||||
if (!pageData) return {};
|
||||
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||
|
||||
// Determine correct localized slug based on current locale
|
||||
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||
|
||||
return {
|
||||
title: pageData.frontmatter.title,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${deSlug}`,
|
||||
en: `${SITE_URL}/en/${enSlug}`,
|
||||
@@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
openGraph: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
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
|
||||
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
||||
@@ -32,7 +32,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
@@ -40,7 +40,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -54,12 +54,19 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
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
|
||||
const rawTextContent = JSON.stringify(post.content);
|
||||
|
||||
|
||||
@@ -59,6 +59,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
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 (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
|
||||
@@ -1,66 +1,137 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { headers } from 'next/headers';
|
||||
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('Error.notFound');
|
||||
const { trackEvent } = useAnalytics();
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations('Error.notFound');
|
||||
|
||||
useEffect(() => {
|
||||
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
type: '404_not_found',
|
||||
path: errorUrl,
|
||||
});
|
||||
// Try to determine the requested path
|
||||
const headersList = await headers();
|
||||
const urlPath = headersList.get('x-invoke-path') || '';
|
||||
|
||||
// Explicitly send the 404 to Sentry so we have visibility into broken links
|
||||
import('@sentry/nextjs').then((Sentry) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('status_code', '404');
|
||||
scope.setTag('path', errorUrl);
|
||||
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning');
|
||||
});
|
||||
});
|
||||
}, [trackEvent]);
|
||||
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,
|
||||
});
|
||||
|
||||
// Check products
|
||||
const productRes =
|
||||
postRes.docs.length === 0
|
||||
? await payload.find({
|
||||
collection: 'products',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all',
|
||||
limit: 1,
|
||||
})
|
||||
: { 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 (
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
<>
|
||||
<ClientNotFoundTracker path={urlPath} />
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
<p className="text-white/60 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">
|
||||
<Button href="/" variant="accent" size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant="outline" size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
</Container>
|
||||
{/* 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" />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||
@@ -75,11 +75,13 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
if (!product) return {};
|
||||
|
||||
const currentLocalePath = await getLocalizedPath(locale);
|
||||
|
||||
return {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
@@ -90,7 +92,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
@@ -114,7 +116,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
'high-voltage-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)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
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() {
|
||||
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
||||
// Further DB health checks can be implemented via Payload Local API later.
|
||||
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ const jsxConverters: JSXConverters = {
|
||||
const startsWithDash = text.trimStart().startsWith('- ');
|
||||
const prefix = startsWithDash ? null : parts.shift();
|
||||
return (
|
||||
<>
|
||||
<div className="my-4">
|
||||
{prefix && (
|
||||
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
||||
<div dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
||||
{!prefix.includes('<') ? prefix : undefined}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
{parts.map((item: string, i: number) => {
|
||||
@@ -64,7 +64,7 @@ const jsxConverters: JSXConverters = {
|
||||
return <li key={i}>{cleanItem}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,14 +118,92 @@ const jsxConverters: JSXConverters = {
|
||||
);
|
||||
}
|
||||
|
||||
if (node.format === 1) return <strong>{text}</strong>;
|
||||
if (node.format === 2) return <em>{text}</em>;
|
||||
return <span>{text}</span>;
|
||||
if (node.format === 1) return <strong key="bold">{text}</strong>;
|
||||
if (node.format === 2) return <em key="italic">{text}</em>;
|
||||
return <span key="text">{text}</span>;
|
||||
},
|
||||
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||
paragraph: ({ children }: any) => (
|
||||
<div className="mb-6 leading-relaxed text-text-secondary">{children}</div>
|
||||
),
|
||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||
heading: ({ node, children }: any) => {
|
||||
const tag = node?.tag;
|
||||
if (tag === 'h1')
|
||||
return (
|
||||
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
|
||||
);
|
||||
if (tag === 'h2')
|
||||
return (
|
||||
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
|
||||
);
|
||||
if (tag === 'h3')
|
||||
return (
|
||||
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
|
||||
);
|
||||
if (tag === 'h4')
|
||||
return (
|
||||
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
|
||||
);
|
||||
if (tag === 'h5')
|
||||
return (
|
||||
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
|
||||
);
|
||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
||||
},
|
||||
list: ({ node, children }: any) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (node?.listType === 'check') {
|
||||
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
|
||||
}
|
||||
return (
|
||||
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
listitem: ({ node, children }: any) => {
|
||||
if (node?.checked != null) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.checked}
|
||||
readOnly
|
||||
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
||||
},
|
||||
quote: ({ children }: any) => (
|
||||
<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, children }: any) => {
|
||||
// Handling Payload CMS link nodes
|
||||
const href = node?.fields?.url || node?.url || '#';
|
||||
const newTab = node?.fields?.newTab || node?.newTab;
|
||||
return (
|
||||
<a
|
||||
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: {
|
||||
// ... preserved existing blocks ...
|
||||
@@ -170,10 +248,10 @@ const jsxConverters: JSXConverters = {
|
||||
/>
|
||||
),
|
||||
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) => {
|
||||
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
|
||||
if (!node?.fields) return null;
|
||||
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
||||
},
|
||||
highlightBox: ({ node }: any) => (
|
||||
@@ -246,20 +324,23 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.title}
|
||||
</SplitHeading>
|
||||
),
|
||||
productTabs: ({ node }: any) => (
|
||||
<ProductTabs
|
||||
technicalData={
|
||||
<ProductTechnicalData
|
||||
data={{
|
||||
technicalItems: node.fields.technicalItems,
|
||||
voltageTables: node.fields.voltageTables,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</ProductTabs>
|
||||
),
|
||||
productTabs: ({ node }: any) => {
|
||||
if (!node?.fields) return null;
|
||||
return (
|
||||
<ProductTabs
|
||||
technicalData={
|
||||
<ProductTechnicalData
|
||||
data={{
|
||||
technicalItems: node.fields.technicalItems,
|
||||
voltageTables: node.fields.voltageTables,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</ProductTabs>
|
||||
);
|
||||
},
|
||||
'block-productTabs': ({ node }: any) => (
|
||||
<ProductTabs
|
||||
technicalData={
|
||||
|
||||
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
||||
import { Section, Container, Heading } from '../../components/ui';
|
||||
|
||||
interface RecentPostsProps {
|
||||
locale: string;
|
||||
@@ -13,7 +13,7 @@ interface RecentPostsProps {
|
||||
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
const recentPosts = posts.slice(0, 3);
|
||||
const recentPosts = posts.slice(0, 4);
|
||||
|
||||
if (recentPosts.length === 0) return null;
|
||||
|
||||
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
const subtitle = data?.subtitle || t('latestNews');
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral py-16 md:py-24">
|
||||
<Container>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||
<Container className="py-12 md:py-16">
|
||||
<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">
|
||||
{title}
|
||||
</Heading>
|
||||
@@ -35,91 +35,73 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="grid grid-cols-1 gap-10 list-none p-0 m-0">
|
||||
{recentPosts.map((post, idx) => (
|
||||
<li key={`${post.slug}-${idx}`}>
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
className="group block h-full focus:outline-none"
|
||||
>
|
||||
<Card
|
||||
tag="article"
|
||||
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[400px] md:min-h-[450px]"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
style={{
|
||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
sizes="(max-width: 768px) 100vw, 100vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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-8 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/60 to-transparent flex flex-col pt-32">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(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 className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl md:text-2xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
|
||||
<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')}
|
||||
</span>
|
||||
<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
|
||||
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||
{recentPosts.map((post, idx) => (
|
||||
<li key={`${post.slug}-${idx}`} className="block">
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
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"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
style={{
|
||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={post.frontmatter.date}
|
||||
suppressHydrationWarning
|
||||
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"
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</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>
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
</div>
|
||||
<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">
|
||||
{t('readMore')}{' '}
|
||||
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
45
lib/blog.ts
45
lib/blog.ts
@@ -59,18 +59,50 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const { docs } = await payload.find({
|
||||
// First try: Find in the requested locale
|
||||
let { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
draft: isDev,
|
||||
draft: config.showDrafts,
|
||||
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;
|
||||
|
||||
const doc = docs[0];
|
||||
@@ -107,15 +139,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
sort: '-date',
|
||||
draft: isDev,
|
||||
draft: config.showDrafts,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ function createConfig() {
|
||||
isStaging: target === 'staging',
|
||||
isTesting: target === 'testing',
|
||||
isDevelopment: target === 'development',
|
||||
showDrafts: target === 'development' || target === 'testing' || target === 'staging',
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||
|
||||
@@ -116,6 +117,9 @@ export const config = {
|
||||
get isDevelopment() {
|
||||
return getConfig().isDevelopment;
|
||||
},
|
||||
get showDrafts() {
|
||||
return getConfig().showDrafts;
|
||||
},
|
||||
get baseUrl() {
|
||||
return getConfig().baseUrl;
|
||||
},
|
||||
|
||||
76
lib/pages.ts
76
lib/pages.ts
@@ -1,5 +1,7 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export interface PageFrontmatter {
|
||||
title: string;
|
||||
@@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData {
|
||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
// Try finding exact match first
|
||||
let result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
and: [
|
||||
{ slug: { equals: fileSlug } },
|
||||
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||
],
|
||||
},
|
||||
locale: locale as any,
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const docs = result.docs as any[];
|
||||
if (!docs || docs.length === 0) return null;
|
||||
return mapDoc(docs[0]);
|
||||
// Fallback: search ALL locales
|
||||
if (result.docs.length === 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) {
|
||||
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export interface ProductFrontmatter {
|
||||
title: string;
|
||||
@@ -26,13 +27,12 @@ export async function getProductMetadata(
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [
|
||||
{ slug: { equals: fileSlug } },
|
||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
||||
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||
],
|
||||
},
|
||||
locale: locale as any,
|
||||
@@ -70,13 +70,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [
|
||||
{ slug: { equals: fileSlug } },
|
||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
||||
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||
],
|
||||
},
|
||||
locale: locale as any,
|
||||
@@ -127,11 +126,10 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
pagination: false,
|
||||
@@ -157,11 +155,10 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
||||
images: true,
|
||||
} as const;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
depth: 1,
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -393,6 +393,7 @@ const nextConfig = {
|
||||
},
|
||||
images: {
|
||||
formats: ['image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
@@ -402,6 +403,14 @@ const nextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: '*.klz-cables.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'klz-cables.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '*.klz-cables.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'klz.localhost',
|
||||
@@ -427,6 +436,31 @@ const nextConfig = {
|
||||
source: '/de/kontakt',
|
||||
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: [],
|
||||
fallback: [],
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@react-three/postprocessing": "^3.0.4",
|
||||
"@sentry/nextjs": "^10.39.0",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"axios": "^1.13.5",
|
||||
@@ -48,7 +45,6 @@
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.183.1",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
@@ -57,7 +53,7 @@
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@cspell/dict-de-de": "^4.1.2",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/eslint-config": "^1.8.21",
|
||||
"@mintel/eslint-config": "1.8.21",
|
||||
"@mintel/tsconfig": "^1.8.21",
|
||||
"@next/bundle-analyzer": "^16.1.6",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
@@ -115,6 +111,8 @@
|
||||
"check:security": "tsx ./scripts/check-security.ts",
|
||||
"check:links": "bash ./scripts/check-links.sh",
|
||||
"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:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"cms:migrate": "payload migrate",
|
||||
|
||||
@@ -86,10 +86,14 @@ export default buildConfig({
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||
port: Number(process.env.MAIL_PORT) || 587,
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
...(process.env.MAIL_USERNAME
|
||||
? {
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
|
||||
735
pnpm-lock.yaml
generated
735
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
119
scripts/check-apis.ts
Normal file
119
scripts/check-apis.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import axios from 'axios';
|
||||
import dns from 'dns';
|
||||
import { promisify } from 'util';
|
||||
import url from 'url';
|
||||
|
||||
const resolve4 = promisify(dns.resolve4);
|
||||
|
||||
// This script verifies that external logging and analytics APIs are reachable
|
||||
// from the deployment environment (which could be behind corporate firewalls or VPNs).
|
||||
|
||||
const umamiEndpoint = process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me';
|
||||
const sentryDsn = process.env.SENTRY_DSN || '';
|
||||
|
||||
async function checkUmami() {
|
||||
console.log(`\n🔍 Checking Umami Analytics API Availability...`);
|
||||
console.log(` Endpoint: ${umamiEndpoint}`);
|
||||
|
||||
try {
|
||||
// Umami usually exposes a /api/heartbeat or /api/health if we know the route.
|
||||
// Trying root or /api/auth/verify (which will give 401 but proves routing works).
|
||||
// A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
|
||||
const response = await axios.get(`${umamiEndpoint.replace(/\/$/, '')}/api/health`, {
|
||||
timeout: 5000,
|
||||
validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
|
||||
});
|
||||
|
||||
// As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
|
||||
if (response.status >= 500) {
|
||||
throw new Error(`Umami API responded with server error HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
// If /api/health fails completely, maybe try a DNS check as a fallback
|
||||
try {
|
||||
console.warn(` ⚠️ HTTP check failed, falling back to DNS resolution...`);
|
||||
const umamiHost = new url.URL(umamiEndpoint).hostname;
|
||||
await resolve4(umamiHost);
|
||||
console.log(` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`);
|
||||
return true;
|
||||
} catch (dnsErr: any) {
|
||||
console.error(
|
||||
` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSentry() {
|
||||
console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`);
|
||||
|
||||
if (!sentryDsn) {
|
||||
console.log(` ℹ️ No SENTRY_DSN provided in environment. Skipping.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedDsn = new url.URL(sentryDsn);
|
||||
const host = parsedDsn.hostname;
|
||||
console.log(` Host: ${host}`);
|
||||
|
||||
// We do a DNS lookup to ensure the runner can actually resolve the tracking server
|
||||
const addresses = await resolve4(host);
|
||||
|
||||
if (addresses && addresses.length > 0) {
|
||||
console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`);
|
||||
|
||||
// Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
|
||||
try {
|
||||
const proto = parsedDsn.protocol || 'https:';
|
||||
await axios.get(`${proto}//${host}/api/0/`, {
|
||||
timeout: 5000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`);
|
||||
} catch (ignore) {
|
||||
console.log(
|
||||
` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
throw new Error('No IP addresses found for DSN host');
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting External API Connectivity Smoke Test...');
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
const umamiOk = await checkUmami();
|
||||
if (!umamiOk) hasErrors = true;
|
||||
|
||||
const sentryOk = await checkSentry();
|
||||
if (!sentryOk) hasErrors = true;
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(
|
||||
`\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
|
||||
);
|
||||
console.error(` This might mean the deployment environment lacks outbound internet access, `);
|
||||
console.error(` DNS is misconfigured, or the upstream services are down.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
170
scripts/check-forms.ts
Normal file
170
scripts/check-forms.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import puppeteer, { HTTPResponse } from 'puppeteer';
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
|
||||
|
||||
// 1. Fetch Sitemap to discover the contact page and a product page
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||
let urls: string[] = [];
|
||||
|
||||
try {
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
const response = await axios.get(sitemapUrl, {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||
urls = $('url loc')
|
||||
.map((i, el) => $(el).text())
|
||||
.get();
|
||||
|
||||
// Normalize to target URL instance
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith('http'))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||
.sort();
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const contactUrl = urls.find((u) => u.includes('/de/kontakt'));
|
||||
// Ensure we select an actual product page (depth >= 7: http://host/de/produkte/category/product)
|
||||
const productUrl = urls.find(
|
||||
(u) =>
|
||||
u.includes('/de/produkte/') && new URL(u).pathname.split('/').filter(Boolean).length >= 4,
|
||||
);
|
||||
|
||||
if (!contactUrl) {
|
||||
console.error(`❌ Could not find contact page in sitemap. Ensure /de/kontakt exists.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!productUrl) {
|
||||
console.error(
|
||||
`❌ Could not find a product page in sitemap. Form testing requires at least one product page.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Discovered Contact Page: ${contactUrl}`);
|
||||
console.log(`✅ Discovered Product Page: ${productUrl}`);
|
||||
|
||||
// 2. Launch Headless Browser
|
||||
console.log(`\n🕷️ Launching Puppeteer Headless Engine...`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// 3. Inject Gatekeeper session bypassing auth screens
|
||||
console.log(`\n🛡️ Injecting Gatekeeper Session...`);
|
||||
await page.setCookie({
|
||||
name: 'klz_gatekeeper_session',
|
||||
value: gatekeeperPassword,
|
||||
domain: new URL(targetUrl).hostname,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: targetUrl.startsWith('https://'),
|
||||
});
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
// 4. Test Contact Form
|
||||
try {
|
||||
console.log(`\n🧪 Testing Contact Form on: ${contactUrl}`);
|
||||
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Ensure React has hydrated completely
|
||||
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Ensure form is visible and interactive
|
||||
try {
|
||||
// Find the form input by name
|
||||
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
|
||||
} catch (e) {
|
||||
console.error('Failed to find Contact Form input. Page Title:', await page.title());
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Fill form fields
|
||||
await page.type('input[name="name"]', 'Automated E2E Test');
|
||||
await page.type('input[name="email"]', 'testing@mintel.me');
|
||||
await page.type(
|
||||
'textarea[name="message"]',
|
||||
'This is an automated test verifying the contact form submission.',
|
||||
);
|
||||
|
||||
console.log(` Submitting Contact Form...`);
|
||||
|
||||
// Explicitly click submit and wait for navigation/state-change
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
|
||||
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Contact Form Test Failed: ${err.message}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// 4. Test Product Quote Form
|
||||
try {
|
||||
console.log(`\n🧪 Testing Product Quote Form on: ${productUrl}`);
|
||||
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Ensure React has hydrated completely
|
||||
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||
|
||||
// The product form uses dynamic IDs, so we select by input type in the specific form context
|
||||
try {
|
||||
await page.waitForSelector('form input[type="email"]', { visible: true, timeout: 15000 });
|
||||
} catch (e) {
|
||||
console.error('Failed to find Product Quote Form input. Page Title:', await page.title());
|
||||
throw e;
|
||||
}
|
||||
|
||||
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
|
||||
await page.type('form input[type="email"]', 'testing@mintel.me');
|
||||
await page.type(
|
||||
'form textarea',
|
||||
'Automated request for product quote via E2E testing framework.',
|
||||
);
|
||||
|
||||
console.log(` Submitting Product Quote Form...`);
|
||||
|
||||
// Submit and wait for success state
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
|
||||
page.click('form button[type="submit"]'),
|
||||
]);
|
||||
|
||||
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// 5. Evaluation
|
||||
if (hasErrors) {
|
||||
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`\n🎉 SUCCESS: All form submissions arrived and handled correctly!`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -50,7 +50,12 @@ async function main() {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
validateStatus: (status) => status < 400,
|
||||
});
|
||||
const filename = `page-${i}.html`;
|
||||
|
||||
// Generate a safe filename that retains URL information
|
||||
const urlStr = new URL(u);
|
||||
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const filename = `${safePath || 'index'}.html`;
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, filename), res.data);
|
||||
} catch (err: any) {
|
||||
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
|
||||
|
||||
@@ -53,13 +53,17 @@ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
REMOTE_DB_USER=""
|
||||
REMOTE_DB_NAME=""
|
||||
|
||||
# Migration names to insert after restore (keeps Payload from prompting)
|
||||
MIGRATIONS=(
|
||||
"20260223_195005_products_collection:1"
|
||||
"20260223_195151_remove_sku_unique:2"
|
||||
"20260225_003500_add_pages_collection:3"
|
||||
"20260225_175000_native_localization:4"
|
||||
)
|
||||
# Auto-detect migrations from src/migrations/*.ts (no manual maintenance needed)
|
||||
MIGRATIONS=()
|
||||
BATCH=1
|
||||
for migration_file in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||
name=$(basename "$migration_file" .ts)
|
||||
MIGRATIONS+=("$name:$BATCH")
|
||||
((BATCH++))
|
||||
done
|
||||
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
|
||||
echo "⚠️ No migration files found in src/migrations/"
|
||||
fi
|
||||
|
||||
# ── Resolve target environment ─────────────────────────────────────────────
|
||||
resolve_target() {
|
||||
@@ -159,6 +163,29 @@ backup_remote_db() {
|
||||
REMOTE_BACKUP_FILE="$file"
|
||||
}
|
||||
|
||||
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
|
||||
check_remote_containers() {
|
||||
echo "🔍 Checking $TARGET containers..."
|
||||
local missing=0
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
|
||||
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
|
||||
echo " → Deploy $TARGET first: git push to trigger pipeline, or run:"
|
||||
echo " ssh $SSH_HOST \"cd $REMOTE_SITE_DIR && docker compose -p $REMOTE_PROJECT --env-file .env.\$TARGET up -d\""
|
||||
missing=1
|
||||
fi
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
|
||||
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
|
||||
missing=1
|
||||
fi
|
||||
if [ $missing -eq 1 ]; then
|
||||
echo ""
|
||||
echo "💡 The $TARGET environment hasn't been deployed yet."
|
||||
echo " Push to the '$TARGET' branch or run the pipeline first."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All $TARGET containers running."
|
||||
}
|
||||
|
||||
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
||||
do_push() {
|
||||
echo ""
|
||||
@@ -172,8 +199,9 @@ do_push() {
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
# 0. Ensure local DB is running
|
||||
# 0. Ensure local DB is running & remote containers exist
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
|
||||
# 1. Safety backup of remote
|
||||
backup_remote_db
|
||||
@@ -227,8 +255,9 @@ do_pull() {
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
# 0. Ensure local DB is running
|
||||
# 0. Ensure local DB is running & remote containers exist
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
|
||||
# 1. Safety backup of local
|
||||
backup_local_db
|
||||
|
||||
Reference in New Issue
Block a user