Compare commits
5 Commits
v2.2.6
...
f1a28b9db2
| Author | SHA1 | Date | |
|---|---|---|---|
| f1a28b9db2 | |||
| 7fb1945ce5 | |||
| ec013a32a2 | |||
| 40e26117bd | |||
| 20fd889751 |
38
.env
38
.env
@@ -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
|
|
||||||
@@ -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
2
.gitignore
vendored
@@ -28,3 +28,5 @@ html-errors*.json
|
|||||||
reference/
|
reference/
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
.env
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -123,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',
|
||||||
@@ -160,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',
|
||||||
@@ -241,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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
157
app/api/ai-search/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
→
|
|
||||||
</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">
|
||||||
|
→
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
88
components/search/AIOrb.tsx
Normal file
88
components/search/AIOrb.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
components/search/AISearchResults.tsx
Normal file
323
components/search/AISearchResults.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
35
lib/blog.ts
35
lib/blog.ts
@@ -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 '';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const nextConfig = {
|
|||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
swcMinify: true,
|
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -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.6",
|
"version": "2.0.2",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
@@ -161,4 +173,4 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"lucide-react": "^0.563.0"
|
"lucide-react": "^0.563.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1314
pnpm-lock.yaml
generated
1314
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|
||||||
@@ -168,38 +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) {
|
|
||||||
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`❌ Cleanup 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);
|
||||||
|
|||||||
@@ -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
134
src/lib/qdrant.ts
Normal 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
22
src/lib/redis.ts
Normal 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;
|
||||||
@@ -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
63
src/scripts/ingest-pdf.ts
Normal 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
20
test-chat2.mjs
Normal 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
16
test-simple.mjs
Normal 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();
|
||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user