Compare commits

..

5 Commits

Author SHA1 Message Date
f1a28b9db2 feat(ai-search): add interactive WebGL Orb, Markdown support, and Sentry tracking
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
2026-02-28 00:03:39 +01:00
7fb1945ce5 Merge branch 'main' into feature/ai-search 2026-02-27 19:08:50 +01:00
ec013a32a2 Merge main into feature/ai-search and resolve conflicts 2026-02-27 18:45:34 +01:00
40e26117bd chore: remove env 2026-02-26 11:27:39 +01:00
20fd889751 feat: ai search 2026-02-26 03:10:15 +01:00
39 changed files with 2748 additions and 760 deletions

38
.env
View File

@@ -1,38 +0,0 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@mg.mintel.me
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
# by docker-compose.yml using these base DB credentials, so you don't need to
# manually write the connection strings here.
PAYLOAD_DB_NAME=payload
PAYLOAD_DB_USER=payload
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

View File

@@ -83,7 +83,7 @@ jobs:
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}" ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.mintel.me" TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}"
fi fi
# Standardize Traefik Rule (escaped backticks for Traefik v3) # Standardize Traefik Rule (escaped backticks for Traefik v3)
@@ -261,6 +261,12 @@ jobs:
# Analytics # Analytics
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' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -319,6 +325,12 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "# Search & AI"
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME" echo "PROJECT_NAME=$PROJECT_NAME"

2
.gitignore vendored
View File

@@ -28,3 +28,5 @@ html-errors*.json
reference/ reference/
# Database backups # Database backups
backups/ backups/
.env

View File

@@ -8,20 +8,6 @@ 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,
}: { }: {
@@ -46,19 +32,12 @@ 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={base64Image || featuredImage} image={featuredImage}
/>, />,
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,

View File

@@ -1,18 +1,12 @@
import { notFound, redirect } 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 { import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
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';
@@ -73,10 +67,6 @@ export default async function BlogPost({ params }: BlogPostProps) {
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale); 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);
@@ -98,7 +88,6 @@ 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={{
@@ -124,7 +113,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
</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} suppressHydrationWarning> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -134,13 +123,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<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"> <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">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
@@ -161,7 +150,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
</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} suppressHydrationWarning> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -171,13 +160,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-neutral-300 rounded-full" /> <span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold"> <span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</header> </header>
@@ -242,10 +231,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
</div> </div>
{/* Right Column: Sticky Sidebar - TOC */} {/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
<aside className="sticky-narrative-sidebar hidden lg:block"> <aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12 lg:sticky lg:top-32"> <div className="space-y-12">
<TableOfContents headings={headings} locale={locale} /> {/* Future Payload Table of Contents Implementation */}
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase"> <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> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -322,8 +322,6 @@ 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,
@@ -355,31 +353,29 @@ 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-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark"> <section className="relative pt-40 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 flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]"> <nav className="flex items-center 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 shrink-0" className="hover:text-accent transition-colors"
> >
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'} {t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<Link <Link
href={`/${locale}/${productsSlug}/${categorySlug}`} href={`/${locale}/${productsSlug}/${categorySlug}`}
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate" className="hover:text-accent transition-colors"
> >
{categoryTitle} {categoryTitle}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<span className="text-white/90 truncate max-w-[140px] md:max-w-none"> <span className="text-white/90">{product.frontmatter.title}</span>
{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">
@@ -390,7 +386,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{t('englishVersion')} {t('englishVersion')}
</div> </div>
)} )}
<div className="flex flex-wrap gap-2 mb-4 md:mb-8"> <div className="flex flex-wrap gap-3 mb-8">
{product.frontmatter.categories.map((cat, idx) => ( {product.frontmatter.categories.map((cat, idx) => (
<Badge <Badge
key={idx} key={idx}
@@ -401,10 +397,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
</Badge> </Badge>
))} ))}
</div> </div>
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase"> <Heading level={1} className="text-white mb-8 uppercase">
{product.frontmatter.title} {product.frontmatter.title}
</Heading> </Heading>
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium"> <p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
{product.frontmatter.description} {product.frontmatter.description}
</p> </p>
</div> </div>
@@ -418,11 +414,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 md:-mt-32 mb-8 md:mb-32 animate-slide-up" className="relative -mt-32 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-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24"> <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="relative w-full aspect-[4/3] md:aspect-[21/9]"> <div className="relative w-full aspect-[21/9]">
<Image <Image
src={product.frontmatter.images[0]} src={product.frontmatter.images[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
@@ -457,10 +453,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-12 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-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5"> <div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
{descriptionChildren.length > 0 ? ( {descriptionChildren.length > 0 ? (
<PayloadRichText data={descriptionContent} /> <PayloadRichText data={descriptionContent} />
) : product.frontmatter.description ? ( ) : product.frontmatter.description ? (
@@ -468,12 +464,6 @@ 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>
@@ -482,7 +472,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
{/* Full-width Technical Data Below */} {/* Full-width Technical Data Below */}
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0"> <div className="mt-16 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>
@@ -540,7 +530,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
{/* Related Products Section */} {/* Related Products Section */}
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5"> <div className="mt-16 pt-16 border-t border-neutral-dark/5">
<RelatedProducts <RelatedProducts
currentSlug={productSlug} currentSlug={productSlug}
categories={product.frontmatter.categories} categories={product.frontmatter.categories}

View File

@@ -72,7 +72,6 @@ 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
@@ -85,30 +84,26 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) { const notificationResult = await sendEmail({
const notificationResult = await sendEmail({ replyTo: email,
replyTo: email, subject: notificationSubject,
subject: notificationSubject, html: notificationHtml,
html: notificationHtml, });
});
if (notificationResult.success) { if (notificationResult.success) {
logger.info('Notification email sent successfully', { logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId, messageId: notificationResult.messageId,
}); });
} else {
logger.error('Notification email FAILED', {
error: notificationResult.error,
subject: notificationSubject,
email,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{ action: 'sendContactFormAction_notification', email },
);
}
} else { } else {
logger.info('Skipping notification email for test submission', { email }); logger.error('Notification email FAILED', {
error: notificationResult.error,
subject: notificationSubject,
email,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{ action: 'sendContactFormAction_notification', email },
);
} }
// 2b. Send confirmation to Customer (branded as KLZ Cables) // 2b. Send confirmation to Customer (branded as KLZ Cables)
@@ -120,30 +115,26 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) { const confirmationResult = await sendEmail({
const confirmationResult = await sendEmail({ to: email,
to: email, subject: confirmationSubject,
subject: confirmationSubject, html: confirmationHtml,
html: confirmationHtml, });
});
if (confirmationResult.success) { if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', { logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId, messageId: confirmationResult.messageId,
}); });
} else {
logger.error('Confirmation email FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email,
});
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`),
{ action: 'sendContactFormAction_confirmation', email },
);
}
} else { } else {
logger.info('Skipping confirmation email for test submission', { email }); logger.error('Confirmation email FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email,
});
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`),
{ action: 'sendContactFormAction_confirmation', email },
);
} }
// Notify via Gotify (Internal) // Notify via Gotify (Internal)

157
app/api/ai-search/route.ts Normal file
View File

@@ -0,0 +1,157 @@
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
import { searchProducts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import { z } from 'zod';
import * as Sentry from '@sentry/nextjs';
// Config and constants
const RATE_LIMIT_POINTS = 5; // 5 requests
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
// Removed requestSchema as it's replaced by direct parsing
export async function POST(req: NextRequest) {
// Changed req type to NextRequest
try {
const { messages, visitorId, honeypot } = await req.json();
// 1. Basic Validation
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
}
const latestMessage = messages[messages.length - 1].content;
const isBot = honeypot && honeypot.length > 0;
// Check if the input itself is obviously spam/too long
if (latestMessage.length > 500) {
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
}
// 2. Honeypot check
if (isBot) {
console.warn('Honeypot triggered in AI search');
// Tarpit the bot
await new Promise((resolve) => setTimeout(resolve, 3000));
return NextResponse.json({
answerText: 'Vielen Dank für Ihre Anfrage.',
products: [],
});
}
// 3. Rate Limiting via Redis
try {
if (visitorId) {
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
if (requestCount === 1) {
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION); // Use constant
}
if (requestCount > RATE_LIMIT_POINTS) {
// Use constant
return NextResponse.json(
{
error: 'Rate limit exceeded. Please try again later.',
},
{ status: 429 },
);
}
}
} catch (redisError) {
// Renamed variable for clarity
console.error('Redis Rate Limiting Error:', redisError); // Changed to error for consistency
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
// Fail open if Redis is down
}
// 4. Fetch Context from Qdrant based on the latest message
let contextStr = '';
let foundProducts: any[] = [];
try {
const searchResults = await searchProducts(latestMessage, 5);
if (searchResults && searchResults.length > 0) {
const productDescriptions = searchResults
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
.map((p: any) => p.payload?.content)
.join('\n\n');
const knowledgeDescriptions = searchResults
.filter((p) => p.payload?.type === 'knowledge')
.map((p: any) => p.payload?.content)
.join('\n\n');
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
foundProducts = searchResults
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
.map((p: any) => p.payload?.data);
}
} catch (e) {
console.error('Qdrant Search Error:', e);
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
// We can still proceed without context if Qdrant fails
}
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
const systemPrompt = `Du bist ein professioneller und extrem kompetenter Sales-Engineer / Consultant der Firma "KLZ Cables".
Deine Aufgabe ist es, Kunden und Interessenten bei der Auswahl von Mittelspannungskabeln, Starkstromkabeln und Infrastrukturausrüstung beratend zur Seite zu stehen.
WICHTIGE REGELN:
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch.
2. Wenn der Kunde vage ist (z.B. "Ich will einen Windpark bauen"), würge ihn NICHT ab. Stelle stattdessen gezielte, professionelle Rückfragen als Berater (z.B. "Für einen Windpark benötigen wir einige Rahmendaten: Reden wir über die Parkverkabelung (Mittelspannung, z.B. 20kV oder 33kV) oder die Netzanbindung? Welche Querschnitte oder Ströme erwarten Sie?").
3. Nutze das bereitgestellte KABELWISSEN und KATALOG-Gedächtnis unten, um deine Antworten zu fundieren.
4. Bleibe stets professionell, lösungsorientiert und leicht technisch (Industrial Aesthetic). Du kannst humorvoll sein, wenn der Nutzer offensichtlich Quatsch fragt, aber lenke es immer elegant zurück zu Kabeln oder Energieinfrastruktur.
5. Antworte in reinem Text (kein Markdown für die Antwort, es sei denn es sind einfache Absätze oder Listen).
6. Wenn genügend Informationen vorhanden sind, präsentiere passende Kabel aus dem Katalog.
7. Oute dich als Berater von KLZ Cables.
VERFÜGBARER KONTEXT:
${contextStr ? contextStr : 'Keine spezifischen Katalogdaten für diese Anfrage gefunden.'}
`;
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
'X-Title': 'KLZ Cables Search AI',
},
body: JSON.stringify({
model: 'mistralai/mistral-large-2407',
temperature: 0.3,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
],
}),
});
if (!fetchRes.ok) {
const errBody = await fetchRes.text();
throw new Error(`OpenRouter API Error: ${errBody}`);
}
const data = await fetchRes.json();
const text = data.choices[0].message.content;
// Return the AI's answer along with any found products
return NextResponse.json({
answerText: text,
products: foundProducts,
});
} catch (error) {
console.error('AI Search API Error:', error);
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -27,7 +27,7 @@ export async function GET() {
} }
} }
const hasErrors = Object.values(checks).some(v => v.startsWith('error')); const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
return NextResponse.json( return NextResponse.json(
{ status: hasErrors ? 'degraded' : 'ok', checks }, { status: hasErrors ? 'degraded' : 'ok', checks },
{ status: hasErrors ? 503 : 200 }, { status: hasErrors ? 503 : 200 },

View File

@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
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';
@@ -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-14 md:py-24 relative overflow-hidden content-visibility-auto"> <footer className="bg-primary text-white 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-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
{/* Brand Column full width on mobile */} {/* Brand Column */}
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8"> <div className="lg:col-span-4 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>
{/* Legal Column */} {/* Links Columns */}
<div className="col-span-1 lg:col-span-2"> <div className="lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm 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,9 +122,8 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Company 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('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">
@@ -190,9 +190,9 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Recent Posts Column full width on mobile */} {/* Recent Posts Column */}
<div className="col-span-2 md:col-span-2 lg:col-span-4"> <div className="lg:col-span-4">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm 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">
@@ -243,7 +243,7 @@ export default function Footer() {
</div> </div>
</div> </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"> <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">
<p>{t('copyright', { year: currentYear })}</p> <p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8"> <div className="flex gap-8">
<Link <Link
@@ -276,6 +276,48 @@ export default function Footer() {
</Link> </Link>
</div> </div>
</div> </div>
{/* Brand & Quality Sub-Footer */}
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
<div>
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
target: 'mintel_agency',
location: 'sub_footer',
})
}
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
>
Website entwickelt von Marc Mintel
</a>
</div>
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
<div className="flex items-center gap-1.5" title="SSL Secured">
<ShieldCheck className="w-3.5 h-3.5" />
<span>SSL Secured</span>
</div>
<div className="flex items-center gap-1.5" title="Green Hosting">
<Leaf className="w-3.5 h-3.5" />
<span>Green Hosting</span>
</div>
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
<Lock className="w-3.5 h-3.5" />
<span>DSGVO Compliant</span>
</div>
<div className="flex items-center gap-1.5" title="WCAG">
<Accessibility className="w-3.5 h-3.5" />
<span>WCAG</span>
</div>
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
<Zap className="w-3.5 h-3.5" />
<span>PageSpeed 90+</span>
</div>
</div>
</div>
</Container> </Container>
</footer> </footer>
); );

View File

@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
import { cn } from './ui'; import { cn } 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 { Search } from 'lucide-react';
import { AISearchResults } from './search/AISearchResults';
export default function Header() { export default function Header() {
const t = useTranslations('Navigation'); const t = useTranslations('Navigation');
@@ -16,6 +18,7 @@ export default function Header() {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
// Extract locale from pathname // Extract locale from pathname
@@ -141,8 +144,7 @@ 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/90 backdrop-blur-md py-3 md:py-4 shadow-2xl': 'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
!isHomePage || isScrolled || isMobileMenuOpen,
}, },
); );
@@ -153,7 +155,9 @@ 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 className="flex-shrink-0 group touch-target fill-mode-both"> <div
className="flex-shrink-0 group touch-target fill-mode-both"
>
<Link <Link
href={`/${currentLocale}`} href={`/${currentLocale}`}
onClick={() => onClick={() =>
@@ -273,6 +277,19 @@ export default function Header() {
<div <div
className="animate-in fade-in zoom-in-95 fill-mode-both" className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '700ms' }} style={{ animationDuration: '600ms', animationDelay: '700ms' }}
>
<button
onClick={() => setIsSearchOpen(true)}
className="hover:text-accent transition-colors p-2"
aria-label="Search"
>
<Search className="w-5 h-5 md:w-6 md:h-6" />
</button>
</div>
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
> >
<Button <Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
@@ -335,138 +352,120 @@ 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/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col', 'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen isMobileMenuOpen
? 'opacity-100 translate-y-0' ? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none', : 'opacity-0 -translate-y-full pointer-events-none',
)} )}
id="mobile-menu" id="mobile-menu"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('menu')} aria-label={t('menu')}
ref={mobileMenuRef} ref={mobileMenuRef}
inert={isMobileMenuOpen ? undefined : true} inert={isMobileMenuOpen ? undefined : true}
> >
{/* Close Button inside overlay */} <nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
<div className="flex justify-end p-6 pt-8"> {menuItems.map((item, idx) => (
<button <div
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300" key={item.href}
aria-label={t('toggleMenu')} className={cn(
onClick={() => { 'transition-all duration-500 transform',
setIsMobileMenuOpen(false); isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
trackEvent(AnalyticsEvents.BUTTON_CLICK, { )}
type: 'mobile_menu', style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
action: 'close', >
}); <Link
}} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
> aria-current={
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"> (
<path item.href === '/'
strokeLinecap="round" ? pathname === `/${currentLocale}` || pathname === '/'
strokeLinejoin="round" : pathname.startsWith(`/${currentLocale}${item.href}`)
strokeWidth={2} )
d="M6 18L18 6M6 6l12 12" ? 'page'
/> : undefined
</svg> }
</button> onClick={() => {
</div> setIsMobileMenuOpen(false);
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"> trackEvent(AnalyticsEvents.LINK_CLICK, {
{menuItems.map((item, idx) => ( label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className={cn(
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
(item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
)}
>
{item.label}
</Link>
</div>
))}
<div <div
key={item.href}
className={cn( className={cn(
'transition-all duration-500 transform', 'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
)} )}
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }} style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
> >
<Link <div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} <div>
aria-current={ <Link
( href={getPathForLocale('en')}
item.href === '/' className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
? pathname === `/${currentLocale}` || pathname === '/' >
: pathname.startsWith(`/${currentLocale}${item.href}`) EN
) </Link>
? 'page' </div>
: undefined <div className="w-px h-6 bg-white/30" />
} <div>
onClick={() => { <Link
setIsMobileMenuOpen(false); href={getPathForLocale('de')}
trackEvent(AnalyticsEvents.LINK_CLICK, { className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
label: item.label, >
href: item.href, DE
location: 'mobile_menu', </Link>
}); </div>
}}
className={cn(
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
(item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
)}
>
{item.label}
</Link>
</div>
))}
<div
className={cn(
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
)}
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
>
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
<div>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
>
EN
</Link>
</div> </div>
<div className="w-px h-6 bg-white/30" />
<div> <div className="w-full max-w-xs">
<Link <Button
href={getPathForLocale('de')} href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`} variant="accent"
size="lg"
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
> >
DE {t('contact')}
</Link> </Button>
</div> </div>
</div> </div>
<div className="w-full max-w-xs"> {/* Bottom Branding */}
<Button <div
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} className={cn(
variant="accent" 'p-12 flex justify-center transition-all duration-700',
size="lg" isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform" )}
> style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
{t('contact')} >
</Button> <Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</div> </div>
</div> </nav>
</div>
</header>
{/* Bottom Branding */} <AISearchResults
<div isOpen={isSearchOpen}
className={cn( onClose={() => setIsSearchOpen(false)}
'p-12 flex justify-center transition-all duration-700', />
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
)}
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</div>
</nav>
</div>
</> </>
); );
} }

View File

@@ -39,89 +39,119 @@ import CTA from '@/components/home/CTA';
const jsxConverters: JSXConverters = { const jsxConverters: JSXConverters = {
...defaultJSXConverters, ...defaultJSXConverters,
// Let the default converters handle text nodes to preserve valid formatting // Let the default converters handle text nodes to preserve valid formatting
// Use div instead of p for paragraphs to allow nested block elements (like the lists above) // If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
paragraph: ({ node, nodesToJSX }: any) => { text: ({ node }: any) => {
return ( const text = node.text;
<div className="mb-6 leading-relaxed text-text-secondary"> // Handle markdown-style lists embedded in text nodes from Markdown migration
{nodesToJSX({ nodes: node.children })} if (text && text.includes('\n- ')) {
</div> const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
); // If first part doesn't start with "- ", it's a prefix paragraph
const startsWithDash = text.trimStart().startsWith('- ');
const prefix = startsWithDash ? null : parts.shift();
return (
<div className="my-4">
{prefix && (
<div dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
{!prefix.includes('<') ? prefix : undefined}
</div>
)}
<ul className="list-disc pl-6 my-4 space-y-2">
{parts.map((item: string, i: number) => {
const cleanItem = item.trim();
if (cleanItem.includes('<')) {
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
}
return <li key={i}>{cleanItem}</li>;
})}
</ul>
</div>
);
}
if (text && (text.includes('<') || text.includes('data-start'))) {
return <span dangerouslySetInnerHTML={{ __html: text }} />;
}
// Handle markdown-style links [text](url) from Markdown migration
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
const parts: React.ReactNode[] = [];
const remaining = text;
let key = 0;
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
let lastIndex = 0;
while ((match = linkRegex.exec(remaining)) !== null) {
if (match.index > lastIndex) {
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
}
parts.push(
<a
key={key++}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
>
{match[1]}
</a>,
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < remaining.length) {
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
}
return <>{parts}</>;
}
// Handle newlines in text nodes — convert to <br> for proper line breaks
if (text && text.includes('\n')) {
const lines = text.split('\n');
return (
<>
{lines.map((line: string, i: number) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</>
);
}
if (node.format === 1) return <strong 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 // Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
heading: ({ node, nodesToJSX }: any) => { heading: ({ node, children }: any) => {
const children = nodesToJSX({ nodes: node.children });
const tag = node?.tag; const tag = node?.tag;
// Extract text to generate an ID for the TOC
// 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;
if (tag === 'h1') if (tag === 'h1')
return ( return (
<h2 <h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
id={id}
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
>
{children}
</h2>
); );
if (tag === 'h2') if (tag === 'h2')
return ( return (
<h3 <h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
id={id}
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
>
{children}
</h3>
); );
if (tag === 'h3') if (tag === 'h3')
return ( return (
<h4 <h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
id={id}
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h4>
); );
if (tag === 'h4') if (tag === 'h4')
return ( return (
<h5 <h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</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') if (tag === 'h5')
return ( return (
<h6 <h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
id={id}
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h6>
); );
return ( return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
{children}
</h6>
);
}, },
list: ({ node, nodesToJSX }: any) => { list: ({ node, children }: any) => {
const children = nodesToJSX({ nodes: node.children });
if (node?.listType === 'number') { if (node?.listType === 'number') {
return ( return (
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold"> <ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
@@ -138,33 +168,28 @@ const jsxConverters: JSXConverters = {
</ul> </ul>
); );
}, },
listitem: ({ node, nodesToJSX }: any) => { listitem: ({ node, children }: any) => {
const children = nodesToJSX({ nodes: node.children });
if (node?.checked != null) { if (node?.checked != null) {
return ( return (
<li className="flex items-start gap-3 mb-2 leading-relaxed"> <li className="flex items-center gap-3 mb-2 leading-relaxed">
<input <input
type="checkbox" type="checkbox"
checked={node.checked} checked={node.checked}
readOnly readOnly
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0" className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
/> />
<div className="flex-1">{children}</div> <span>{children}</span>
</li> </li>
); );
} }
return <li className="mb-2 leading-relaxed block">{children}</li>; return <li className="mb-2 leading-relaxed">{children}</li>;
}, },
quote: ({ node, nodesToJSX }: any) => { quote: ({ children }: any) => (
const children = nodesToJSX({ nodes: node.children }); <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">
return ( {children}
<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"> </blockquote>
{children} ),
</blockquote> link: ({ node, children }: any) => {
);
},
link: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children });
// Handling Payload CMS link nodes // Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#'; const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab; const newTab = node?.fields?.newTab || node?.newTab;
@@ -1065,10 +1090,6 @@ 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: {

View File

@@ -38,14 +38,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
}; };
return ( return (
<div className="space-y-8 md:space-y-16"> <div className="space-y-16">
{technicalItems.length > 0 && ( {technicalItems.length > 0 && (
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5"> <div className="bg-white p-8 md:p-12 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-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8"> <dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => ( {technicalItems.map((item, idx) => (
<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">
@@ -72,7 +72,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
return ( return (
<div <div
key={idx} key={idx}
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden" className="bg-white p-8 md:p-12 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 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
</h3> </h3>
{table.metaItems.length > 0 && ( {table.metaItems.length > 0 && (
<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"> <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">
{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,11 +98,9 @@ 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-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${ className={`overflow-x-auto -mx-8 md:-mx-12 px-8 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]'
}`} }`}
> >

View File

@@ -9,9 +9,6 @@ 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);
@@ -37,7 +34,6 @@ export default function AnalyticsShell() {
<Suspense fallback={null}> <Suspense fallback={null}>
<DynamicAnalyticsProvider /> <DynamicAnalyticsProvider />
<DynamicScrollDepthTracker /> <DynamicScrollDepthTracker />
<DynamicWebVitalsTracker />
</Suspense> </Suspense>
); );
} }

View File

@@ -1,54 +0,0 @@
'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;
}

View File

@@ -6,6 +6,10 @@ import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events'; import { AnalyticsEvents } from '../analytics/analytics-events';
import AIOrb from '../search/AIOrb';
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) { export default function Hero({ data }: { data?: any }) {
@@ -13,113 +17,148 @@ export default function Hero({ data }: { data?: any }) {
const locale = useLocale(); const locale = useLocale();
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
setIsSearchOpen(true);
}
};
return ( return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0"> <>
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none"> <Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<div className="max-w-5xl mx-auto md:mx-0"> <Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div> <div className="max-w-5xl mx-auto md:mx-0">
<Heading
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]"
>
{data?.title ? (
<>
{data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => {
if (part.startsWith('<green>') && part.endsWith('</green>')) {
const content = part.replace(/<\/?green>/g, '');
return (
<span key={i} className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">
{content}
</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>
);
}
return <span key={i}>{part}</span>;
})}
</>
) : (
t.rich('title', {
green: (chunks) => (
<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>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<div> <div>
<Heading
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]"
>
{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>',
),
}}
/>
) : (
t.rich('title', {
green: (chunks) => (
<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>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<form
onSubmit={handleSearchSubmit}
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
>
<div className="absolute left-2 w-12 h-12 flex items-center justify-center opacity-80 pointer-events-none">
<AIOrb isThinking={false} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Projekt beschreiben oder Kabel suchen..."
className="flex-1 bg-transparent border-none text-white pl-12 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
/>
<Button <Button
href="/contact" type="submit"
variant="accent" variant="accent"
size="lg" size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform" className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
> >
{data?.ctaLabel || t('cta')} Fragen
<span className="transition-transform group-hover/btn:translate-x-1 ml-2"> <ChevronRight className="w-5 h-5 ml-2 -mr-1" />
&rarr;
</span>
</Button>
</div>
<div>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{data?.secondaryCtaLabel || t('exploreProducts')}
</Button> </Button>
</form>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<div>
<Button
href="/contact"
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</span>
</Button>
</div>
<div>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="outline"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{data?.secondaryCtaLabel || t('exploreProducts')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </Container>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both"> <div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration /> <HeroIllustration />
</div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div> </div>
</div>
</Section> <div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div>
</div>
</Section>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
initialQuery={searchQuery}
triggerSearch={true}
/>
</>
); );
} }

View File

@@ -74,7 +74,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning 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" 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 || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -0,0 +1,88 @@
/* eslint-disable react/no-unknown-property */
'use client';
import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Sphere, MeshDistortMaterial, Environment, Float } from '@react-three/drei';
import * as THREE from 'three';
interface AIOrbProps {
isThinking: boolean;
}
function Orb({ isThinking }: AIOrbProps) {
const meshRef = useRef<THREE.Mesh>(null);
const materialRef = useRef<any>(null);
// Dynamic properties based on state
const targetDistort = isThinking ? 0.6 : 0.3;
const targetSpeed = isThinking ? 5 : 2;
const color = isThinking ? '#00FF88' : '#00A3FF'; // Green/Blue based on thinking state
useFrame((state) => {
if (!materialRef.current) return;
// Smoothly interpolate material properties
materialRef.current.distort = THREE.MathUtils.lerp(
materialRef.current.distort,
targetDistort,
0.1,
);
materialRef.current.speed = THREE.MathUtils.lerp(materialRef.current.speed, targetSpeed, 0.1);
// Smooth color transition
const currentColor = materialRef.current.color;
const targetColorObj = new THREE.Color(color);
currentColor.lerp(targetColorObj, 0.05);
// Slow rotation
if (meshRef.current) {
meshRef.current.rotation.x = state.clock.getElapsedTime() * 0.2;
meshRef.current.rotation.y = state.clock.getElapsedTime() * 0.3;
}
});
return (
<Float
speed={isThinking ? 4 : 2}
rotationIntensity={isThinking ? 2 : 1}
floatIntensity={isThinking ? 2 : 1}
>
<Sphere ref={meshRef} args={[1, 64, 64]} scale={1.5}>
<MeshDistortMaterial
ref={materialRef}
color="#00A3FF"
envMapIntensity={2}
clearcoat={1}
clearcoatRoughness={0}
metalness={0.8}
roughness={0.1}
distort={0.3}
speed={2}
/>
</Sphere>
</Float>
);
}
export default function AIOrb({ isThinking = false }: AIOrbProps) {
return (
<div className="w-full h-full min-w-[32px] min-h-[32px] relative flex items-center justify-center">
{/* Ambient glow effect behind the orb */}
<div
className={`absolute inset-0 rounded-full blur-xl opacity-50 transition-colors duration-1000 ${isThinking ? 'bg-[#00FF88]/50' : 'bg-[#00A3FF]/40'}`}
/>
<Canvas
camera={{ position: [0, 0, 4], fov: 45 }}
className="w-full h-full cursor-pointer z-10 block"
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1.5} />
<directionalLight position={[-10, -10, -5]} intensity={0.5} color="#00FF88" />
<Orb isThinking={isThinking} />
<Environment preset="city" />
</Canvas>
</div>
);
}

View File

@@ -0,0 +1,323 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { Search, X, Sparkles, ChevronRight, MessageSquareWarning } from 'lucide-react';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import AIOrb from './AIOrb';
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
}
interface Message {
role: 'user' | 'assistant';
content: string;
products?: ProductMatch[];
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean; // If true, immediately searches on mount with initialQuery
}
export function AISearchResults({
isOpen,
onClose,
initialQuery = '',
triggerSearch = false,
}: ComponentProps) {
const { trackEvent } = useAnalytics();
const [query, setQuery] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
setTimeout(() => inputRef.current?.focus(), 100);
if (triggerSearch && initialQuery && messages.length === 0) {
setQuery(initialQuery);
handleSearch(initialQuery);
} else if (!triggerSearch) {
setQuery('');
}
} else {
document.body.style.overflow = 'unset';
setQuery('');
setMessages([]);
setError(null);
setIsLoading(false);
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen, triggerSearch]);
useEffect(() => {
if (isOpen && initialQuery && messages.length === 0) {
setQuery(initialQuery);
}
}, [initialQuery, isOpen]);
useEffect(() => {
// Auto-scroll to bottom of chat
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
const newUserMessage: Message = { role: 'user', content: searchQuery };
const newMessagesContext = [...messages, newUserMessage];
setMessages(newMessagesContext);
setQuery('');
setIsLoading(true);
setError(null);
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_search',
query: searchQuery,
});
try {
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: newMessagesContext,
_honeypot: honeypot,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch search results');
}
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: data.answerText,
products: data.products,
},
]);
// Re-focus input after response so user can continue typing easily
setTimeout(() => inputRef.current?.focus(), 100);
} catch (err: any) {
console.error(err);
setError(err.message || 'An error occurred while chatting. Please try again.');
trackEvent(AnalyticsEvents.ERROR, {
location: 'ai_search_results',
message: err.message,
query: searchQuery,
});
} finally {
setIsLoading(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
// Handle clicking outside to close
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 bg-primary/95 backdrop-blur-xl transition-all duration-300 animate-in fade-in"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div
ref={modalRef}
className="relative w-full max-w-4xl bg-[#002b49]/90 border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden flex flex-col h-[75vh] animate-in slide-in-from-bottom-10"
>
{/* Header */}
<div className="p-4 md:p-6 flex items-center justify-between border-b border-white/10 relative z-10 bg-[#001c30]">
<div className="flex items-center">
<Sparkles className="w-5 h-5 text-accent mr-3" />
<h2 className="text-white font-bold tracking-widest uppercase text-sm">
KLZ AI Consultant
</h2>
</div>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors p-2"
aria-label="Close"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Chat History Area */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
{messages.length === 0 && !isLoading && !error && (
<div className="flex flex-col items-center justify-center h-full text-center opacity-50 space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<AIOrb isThinking={false} />
<p className="text-xl md:text-2xl font-bold mt-6">I am your technical consultant.</p>
<p className="text-sm">
Describe your project, ask for specific cables, or tell me your requirements.
</p>
</div>
)}
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-2xl p-5 ${msg.role === 'user' ? 'bg-accent text-primary rounded-tr-sm' : 'bg-white/10 border border-white/10 text-white rounded-tl-sm'}`}
>
{msg.role === 'assistant' && (
<h3 className="text-xs font-bold tracking-widest uppercase text-accent/80 mb-2 flex items-center">
<Sparkles className="w-3 h-3 mr-1" />
AI Assistant
</h3>
)}
<div className="text-base md:text-lg leading-relaxed font-medium prose prose-invert prose-p:leading-relaxed prose-pre:bg-black/50 prose-a:text-accent prose-strong:text-accent prose-ul:list-disc prose-ol:list-decimal">
{msg.role === 'assistant' ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
{/* Product Matches inside Assistant Message */}
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
<div className="mt-6 space-y-3 border-t border-white/10 pt-4">
<h4 className="text-xs font-bold tracking-widest uppercase text-white/50">
Empfohlene Produkte
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{msg.products.map((product, idx) => (
<Link
key={idx}
href={`/produkte/${product.slug}`}
onClick={() => {
onClose();
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: product.slug,
location: 'ai_search_results',
});
}}
className="group flex flex-col justify-between bg-white text-primary rounded-lg p-4 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
>
<div>
<p className="text-[10px] font-bold text-primary/50 tracking-wider mb-1">
{product.sku}
</p>
<h5 className="text-sm font-extrabold mb-2 group-hover:text-accent transition-colors line-clamp-2">
{product.title}
</h5>
</div>
<div className="flex items-center justify-end text-[10px] font-bold tracking-widest uppercase mt-2">
<span className="group-hover:text-accent transition-colors">
Details
</span>
<ChevronRight className="w-3 h-3 ml-1 group-hover:text-accent transition-colors group-hover:translate-x-1" />
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-transparent rounded-2xl p-2 w-24 flex justify-center">
<AIOrb isThinking={true} />
</div>
</div>
)}
{error && (
<div className="flex items-start space-x-4 bg-red-500/10 border border-red-500/20 p-4 rounded-xl mt-4">
<MessageSquareWarning className="w-6 h-6 text-red-400 shrink-0" />
<div>
<h3 className="text-sm font-bold text-red-200">System Error</h3>
<p className="text-xs text-red-300 mt-1">{error}</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 md:p-6 bg-[#001c30] border-t border-white/10">
<div className="relative flex items-center bg-white/5 border border-white/10 rounded-xl focus-within:border-accent/50 focus-within:bg-white/10 transition-all">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type your question or requirements..."
className="flex-1 bg-transparent border-none text-white text-base md:text-lg p-4 focus:outline-none placeholder:text-white/30"
disabled={isLoading}
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<button
onClick={() => handleSearch()}
disabled={!query.trim() || isLoading}
className="p-4 text-white/50 hover:text-accent disabled:opacity-50 disabled:hover:text-white/50 transition-colors shrink-0 cursor-pointer"
aria-label="Send message"
>
<Search className="w-6 h-6" />
</button>
</div>
<div className="text-center mt-3">
<span className="text-[10px] uppercase tracking-widest font-bold text-white/30">
Press Enter to send Esc to close
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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=8192" NODE_OPTIONS: "--max-old-space-size=4096"
UV_THREADPOOL_SIZE: "4" UV_THREADPOOL_SIZE: "4"
NPM_TOKEN: ${NPM_TOKEN:-} NPM_TOKEN: ${NPM_TOKEN:-}
CI: "true" CI: "true"
@@ -48,7 +48,7 @@ services:
cpus: '4' cpus: '4'
memory: 8G memory: 8G
command: > command: >
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0" sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
@@ -75,6 +75,24 @@ services:
ports: ports:
- "54322:5432" - "54322:5432"
klz-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- default
ports:
- "6379:6379"
klz-qdrant:
image: qdrant/qdrant:v1.13.2
restart: unless-stopped
volumes:
- klz_qdrant_data:/qdrant/storage
networks:
- default
ports:
- "6333:6333"
networks: networks:
default: default:
name: ${PROJECT_NAME:-klz-cables}-internal name: ${PROJECT_NAME:-klz-cables}-internal
@@ -84,6 +102,8 @@ networks:
volumes: volumes:
klz_db_data: klz_db_data:
external: false external: false
klz_qdrant_data:
external: false
klz_node_modules: klz_node_modules:
klz_next_cache: klz_next_cache:
klz_turbo_cache: klz_turbo_cache:

View File

@@ -100,6 +100,25 @@ services:
networks: networks:
- default - default
klz-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- default
klz-qdrant:
image: qdrant/qdrant:v1.13.2
restart: unless-stopped
ports:
- "6333:6333"
environment:
QDRANT__SERVICE__HTTP_PORT: 6333
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
- klz_qdrant_data:/qdrant/storage
networks:
- default
networks: networks:
default: default:
name: ${PROJECT_NAME:-klz-cables}-internal name: ${PROJECT_NAME:-klz-cables}-internal
@@ -111,3 +130,5 @@ volumes:
external: false external: false
klz_media_data: klz_media_data:
external: false external: false
klz_qdrant_data:
external: false

View File

@@ -286,38 +286,3 @@ 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 '';
}

View File

@@ -18,7 +18,6 @@ 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(
@@ -114,7 +113,6 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
: 50, : 50,
}, },
content: doc.content, content: doc.content,
application: doc.application,
}; };
} }
@@ -197,7 +195,6 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
: 50, : 50,
}, },
content: null, content: null,
application: null,
}; };
}); });

View File

@@ -65,15 +65,7 @@ export function getServerAppServices(): AppServices {
} }
const errors = config.errors.glitchtip.enabled const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService( ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
{
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) {

View File

@@ -69,15 +69,7 @@ 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( ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
{
enabled: true,
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 0.1, // Default to 10% sampling
},
logger,
notifications,
)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (sentryEnabled) { if (sentryEnabled) {

View File

@@ -8,8 +8,6 @@ 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.
@@ -48,12 +46,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' && this.options.enabled) { if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
Sentry.init({ Sentry.init({
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1', dsn: 'https://public@errors.infra.mintel.me/1',
tunnel: '/errors/api/relay', tunnel: '/errors/api/relay',
enabled: true, enabled: true,
tracesSampleRate: this.options.tracesSampleRate ?? 0.1, tracesSampleRate: 0,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
}); });

View File

@@ -17,7 +17,6 @@ const nextConfig = {
workerThreads: false, workerThreads: false,
}, },
reactStrictMode: false, reactStrictMode: false,
swcMinify: true,
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
logging: { logging: {
fetches: { fetches: {

View File

@@ -4,19 +4,25 @@
"private": true, "private": true,
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.18.3",
"dependencies": { "dependencies": {
"@mintel/mail": "^1.8.21", "@ai-sdk/google": "^3.0.31",
"@mintel/next-config": "^1.8.21", "@ai-sdk/openai": "^3.0.36",
"@mintel/next-feedback": "^1.8.21", "@mintel/mail": "^1.9.0",
"@mintel/next-utils": "^1.8.21", "@mintel/next-config": "^1.9.0",
"@mintel/next-feedback": "^1.9.0",
"@mintel/next-utils": "^1.9.0",
"@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",
"@payloadcms/richtext-lexical": "^3.77.0", "@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0", "@payloadcms/ui": "^3.77.0",
"@qdrant/js-client-rest": "^1.17.0",
"@react-email/components": "^1.0.7", "@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.39.0", "@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1", "@types/recharts": "^2.0.1",
"ai": "^6.0.101",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
@@ -24,6 +30,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"i18next": "^25.7.3", "i18next": "^25.7.3",
"import-in-the-middle": "^1.11.0", "import-in-the-middle": "^1.11.0",
"ioredis": "^5.9.3",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.1.6", "next": "16.1.6",
@@ -38,13 +45,17 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-email": "^5.2.5", "react-email": "^5.2.5",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"require-in-the-middle": "^8.0.1", "require-in-the-middle": "^8.0.1",
"resend": "^3.5.0", "resend": "^3.5.0",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"svg-to-pdfkit": "^0.1.8", "svg-to-pdfkit": "^0.1.8",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"xlsx": "npm:@e965/xlsx@^0.20.3", "xlsx": "npm:@e965/xlsx@^0.20.3",
"zod": "3.25.76" "zod": "3.25.76"
}, },
@@ -53,8 +64,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.21", "@mintel/eslint-config": "^1.9.0",
"@mintel/tsconfig": "^1.8.21", "@mintel/tsconfig": "^1.9.0",
"@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",
@@ -80,6 +91,7 @@
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1", "pa11y-ci": "^4.0.1",
"pdf-parse": "^2.4.5",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"puppeteer": "^24.37.3", "puppeteer": "^24.37.3",
@@ -139,7 +151,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.8", "version": "2.0.2",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",

1314
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -66,29 +66,16 @@ async function main() {
const page = await browser.newPage(); const page = await browser.newPage();
// 3. Authenticate through Gatekeeper login form // 3. Inject Gatekeeper session bypassing auth screens
console.log(`\n🛡 Authenticating through Gatekeeper...`); console.log(`\n🛡 Injecting Gatekeeper Session...`);
try { await page.setCookie({
// Navigate to a protected page so Gatekeeper redirects us to the login screen name: 'klz_gatekeeper_session',
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 }); value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
// Check if we landed on the Gatekeeper login page path: '/',
const isGatekeeperPage = await page.$('input[name="password"]'); httpOnly: true,
if (isGatekeeperPage) { secure: targetUrl.startsWith('https://'),
await page.type('input[name="password"]', gatekeeperPassword); });
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
page.click('button[type="submit"]'),
]);
console.log(`✅ Gatekeeper authentication successful!`);
} else {
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
}
} catch (err: any) {
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
await browser.close();
process.exit(1);
}
let hasErrors = false; let hasErrors = false;
@@ -98,7 +85,7 @@ async function main() {
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely // Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => { }); await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
// Ensure form is visible and interactive // Ensure form is visible and interactive
try { try {
@@ -109,9 +96,6 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// Fill form fields // Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test'); await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me'); await page.type('input[name="email"]', 'testing@mintel.me');
@@ -120,9 +104,6 @@ async function main() {
'This is an automated test verifying the contact form submission.', 'This is an automated test verifying the contact form submission.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Contact Form...`); console.log(` Submitting Contact Form...`);
// Explicitly click submit and wait for navigation/state-change // Explicitly click submit and wait for navigation/state-change
@@ -143,7 +124,7 @@ async function main() {
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely // Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => { }); 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 // The product form uses dynamic IDs, so we select by input type in the specific form context
try { try {
@@ -153,9 +134,6 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// In RequestQuoteForm, the email input is type="email" and message is a textarea. // 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 input[type="email"]', 'testing@mintel.me');
await page.type( await page.type(
@@ -163,9 +141,6 @@ async function main() {
'Automated request for product quote via E2E testing framework.', 'Automated request for product quote via E2E testing framework.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Product Quote Form...`); console.log(` Submitting Product Quote Form...`);
// Submit and wait for success state // Submit and wait for success state
@@ -180,43 +155,9 @@ async function main() {
hasErrors = true; hasErrors = true;
} }
// 5. Cleanup: Delete test submissions from Payload CMS
console.log(`\n🧹 Starting cleanup of test submissions...`);
try {
const apiUrl = `${targetUrl.replace(/\/$/, '')}/api/form-submissions`;
const searchUrl = `${apiUrl}?where[email][equals]=testing@mintel.me`;
// Fetch test submissions
const searchResponse = await axios.get(searchUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const testSubmissions = searchResponse.data.docs || [];
console.log(` Found ${testSubmissions.length} test submissions to clean up.`);
for (const doc of testSubmissions) {
try {
await axios.delete(`${apiUrl}/${doc.id}`, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
console.log(` ✅ Deleted submission: ${doc.id}`);
} catch (delErr: any) {
// Log but don't fail, 403s on Directus / Payload APIs for guest Gatekeeper sessions are normal
console.warn(` ⚠️ Cleanup attempt on ${doc.id} returned an error, typically due to API Auth separation: ${delErr.message}`);
}
}
} catch (err: any) {
if (err.response?.status === 403) {
console.warn(` ⚠️ Cleanup fetch failed with 403 Forbidden. This is expected if the runner lacks admin API credentials. Test submissions remain in the database.`);
} else {
console.error(` ❌ Cleanup fetch failed: ${err.message}`);
}
// Don't mark the whole test as failed just because cleanup failed
}
await browser.close(); await browser.close();
// 6. Evaluation // 5. Evaluation
if (hasErrors) { if (hasErrors) {
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`); console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
process.exit(1); process.exit(1);

View File

@@ -1,5 +1,4 @@
// 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.
import * as Sentry from '@sentry/nextjs'; export {};
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

134
src/lib/qdrant.ts Normal file
View File

@@ -0,0 +1,134 @@
import { QdrantClient } from '@qdrant/js-client-rest';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
const qdrantUrl =
process.env.QDRANT_URL ||
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
export const qdrant = new QdrantClient({
url: qdrantUrl,
apiKey: qdrantApiKey || undefined,
});
export const COLLECTION_NAME = 'klz_products';
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
/**
* Ensure the collection exists in Qdrant.
*/
export async function ensureCollection() {
try {
const collections = await qdrant.getCollections();
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
if (!exists) {
await qdrant.createCollection(COLLECTION_NAME, {
vectors: {
size: VECTOR_SIZE,
distance: 'Cosine',
},
});
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
}
} catch (error) {
console.error('Error ensuring Qdrant collection:', error);
}
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
*/
export async function generateEmbedding(text: string): Promise<number[]> {
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
'X-Title': 'KLZ Cables Search AI',
},
body: JSON.stringify({
model: 'openai/text-embedding-3-small',
input: text,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
);
}
const data = await response.json();
return data.data[0].embedding;
}
/**
* Upsert a product into Qdrant
*/
export async function upsertProductVector(
id: string | number,
text: string,
payload: Record<string, any>,
) {
try {
await ensureCollection();
const vector = await generateEmbedding(text);
await qdrant.upsert(COLLECTION_NAME, {
wait: true,
points: [
{
id: id,
vector,
payload,
},
],
});
} catch (error) {
console.error('Error writing to Qdrant:', error);
}
}
/**
* Delete a product from Qdrant
*/
export async function deleteProductVector(id: string | number) {
try {
await ensureCollection();
await qdrant.delete(COLLECTION_NAME, {
wait: true,
points: [id] as [string | number],
});
} catch (error) {
console.error('Error deleting from Qdrant:', error);
}
}
/**
* Search products in Qdrant
*/
export async function searchProducts(query: string, limit = 5) {
try {
await ensureCollection();
const vector = await generateEmbedding(query);
const results = await qdrant.search(COLLECTION_NAME, {
vector,
limit,
with_payload: true,
});
return results;
} catch (error) {
console.error('Error searching in Qdrant:', error);
return [];
}
}

22
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,22 @@
import Redis from 'ioredis';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
const redisUrl =
process.env.REDIS_URL ||
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
// Only create a single instance in Node.js
const globalForRedis = global as unknown as { redis: Redis };
export const redis =
globalForRedis.redis ||
new Redis(redisUrl, {
maxRetriesPerRequest: 3,
});
if (process.env.NODE_ENV !== 'production') {
globalForRedis.redis = redis;
}
export default redis;

View File

@@ -37,6 +37,51 @@ export const Products: CollectionConfig = {
}; };
}, },
}, },
hooks: {
afterChange: [
async ({ doc, req, operation }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if product is published
if (doc._status !== 'published') {
await deleteProductVector(doc.id);
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
} else {
// Serialize payload
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
const payload = {
id: doc.id,
title: doc.title,
sku: doc.sku,
slug: doc.slug,
description: doc.description,
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
};
await upsertProductVector(doc.id, contentText, payload);
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(id as string | number);
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
}
},
],
},
fields: [ fields: [
{ {
name: 'title', name: 'title',

63
src/scripts/ingest-pdf.ts Normal file
View File

@@ -0,0 +1,63 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// Override Qdrant URL for local script execution outside docker
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';
import { upsertProductVector } from '../lib/qdrant';
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
async function ingestPDF(txtPath: string) {
if (!fs.existsSync(txtPath)) {
console.error(`File not found: ${txtPath}`);
process.exit(1);
}
try {
const text = fs.readFileSync(txtPath, 'utf8');
// Simple sentence/paragraph chunking
// We split by standard paragraph breaks (double newline) or large content blocks.
const chunks = text
.split(/\n\s*\n/)
.map((c) => c.trim())
.filter((c) => c.length > 50);
console.log(`Extracted ${text.length} characters from PDF.`);
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
for (let i = 0; i < chunks.length; i++) {
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
// stringing multiple paragraphs if they are short, or cutting them if too long.
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
const chunkText = chunks[i];
// Generate a synthetic ID that won't collide with Payload Product IDs
// Qdrant strictly requires UUID or unsigned int.
const syntheticId = crypto.randomUUID();
const payloadData = {
type: 'knowledge', // Custom flag to differentiate from 'product'
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
content: chunkText,
source: 'Kabelhandbuch KLZ.pdf',
};
// Use the existing upsert function since it just embeds the text and stores the payload
await upsertProductVector(syntheticId, chunkText, payloadData);
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
}
console.log('🎉 PDF Ingestion Complete!');
process.exit(0);
} catch (err) {
console.error('Failed to parse PDF:', err);
process.exit(1);
}
}
// Run mapping
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
ingestPDF(targetTxt);

20
test-chat2.mjs Normal file
View File

@@ -0,0 +1,20 @@
import fetch from 'node-fetch';
async function test() {
const messages = [
{ role: 'user', content: 'Ich will einen Windpark bauen' }
];
console.log('Sending message:', messages[0].content);
const res = await fetch('http://localhost:3000/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages })
});
const data = await res.json();
console.log('\nAI Response:', data);
}
test().catch(console.error);

16
test-simple.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY,
});
async function run() {
const { text } = await generateText({
model: openrouter('mistralai/mistral-large-2407'),
prompt: 'Hello world! Reply in one word.',
});
console.log('Result:', text);
}
run();

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
const BASE_URL = const BASE_URL = process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
describe('OG Image Generation', () => { describe('OG Image Generation', () => {
const locales = ['de', 'en']; const locales = ['de', 'en'];
@@ -19,9 +18,7 @@ describe('OG Image Generation', () => {
return; return;
} }
} }
console.log( console.log(`\n⚠ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`);
`\n⚠ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`,
);
} catch (e) { } catch (e) {
isServerUp = false; isServerUp = false;
} }
@@ -37,7 +34,7 @@ describe('OG Image Generation', () => {
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A // Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
expect(bytes[0]).toBe(0x89); expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50); expect(bytes[1]).toBe(0x50);
expect(bytes[2]).toBe(0x4e); expect(bytes[2]).toBe(0x4E);
expect(bytes[3]).toBe(0x47); expect(bytes[3]).toBe(0x47);
// Check that the image is not empty and has a reasonable size // Check that the image is not empty and has a reasonable size
@@ -52,9 +49,7 @@ describe('OG Image Generation', () => {
await verifyImageResponse(response); await verifyImageResponse(response);
}, 30000); }, 30000);
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ skip }) => {
skip,
}) => {
if (!isServerUp) skip(); if (!isServerUp) skip();
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`; const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
const response = await fetch(url); const response = await fetch(url);
@@ -69,26 +64,11 @@ describe('OG Image Generation', () => {
}, 30000); }, 30000);
}); });
it('should generate static blog overview OG image', async ({ skip }) => { it('should generate blog OG image', async ({ skip }) => {
if (!isServerUp) skip(); if (!isServerUp) skip();
const url = `${BASE_URL}/de/blog/opengraph-image`; const url = `${BASE_URL}/de/blog/opengraph-image`;
const response = await fetch(url); const response = await fetch(url);
await verifyImageResponse(response); await verifyImageResponse(response);
}, 30000); }, 30000);
it('should generate dynamic blog post OG image', async ({ skip }) => {
if (!isServerUp) skip();
// Assuming 'hello-world' or a newly created post slug.
// If it 404s, it still tests the routing, though 200 is expected for an actual post.
const url = `${BASE_URL}/de/blog/hello-world/opengraph-image`;
const response = await fetch(url);
// Even if the post "hello-world" doesn't exist and returns 404 in some environments,
// we should at least check it doesn't 500. We'll accept 200 or 404 as valid "working" states
// vs a 500 compilation/satori error.
expect([200, 404]).toContain(response.status);
if (response.status === 200) {
await verifyImageResponse(response);
}
}, 30000);
}); });