Compare commits
21 Commits
v2.2.0
...
34bb91c04b
| Author | SHA1 | Date | |
|---|---|---|---|
| 34bb91c04b | |||
| 449b7bc8aa | |||
| b033142599 | |||
| 02be8e59b2 | |||
| d2418b5720 | |||
| 501f9659a1 | |||
| e9ceae3989 | |||
| ec3f2cf8c9 | |||
| 3a61d01384 | |||
| 17ebde407e | |||
| 56cd1fb1ba | |||
| 437dd35c9c | |||
| 0cb96dfbac | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| ce719a1d70 | |||
| bd2f92125b |
1
.env
1
.env
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.infra.mintel.me -u "${{ github.actor }}" --password-stdin
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
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' }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
tags: git.infra.mintel.me/mmintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.GITHUB_TOKEN }}' | docker login git.infra.mintel.me -u '${{ github.actor }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1,2 +1,2 @@
|
||||
@mintel:registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:v1.8.20 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
@@ -52,7 +52,7 @@ ENV UV_THREADPOOL_SIZE=3
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:v1.8.20 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||
|
||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
@@ -32,12 +46,19 @@ export default async function Image({
|
||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: 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(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
image={base64Image || featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
||||
import {
|
||||
getPostBySlug,
|
||||
getAdjacentPosts,
|
||||
getReadingTime,
|
||||
extractLexicalHeadings,
|
||||
} from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
@@ -67,6 +73,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
|
||||
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
|
||||
const rawTextContent = JSON.stringify(post.content);
|
||||
|
||||
@@ -113,7 +123,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</Heading>
|
||||
<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>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -150,7 +160,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -231,10 +241,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
||||
{/* Right Column: Sticky Sidebar - TOC */}
|
||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||
<div className="space-y-12">
|
||||
{/* Future Payload Table of Contents Implementation */}
|
||||
<div className="space-y-12 lg:sticky lg:top-32">
|
||||
<TableOfContents headings={headings} locale={locale} />
|
||||
</div>
|
||||
</aside>
|
||||
</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">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
||||
let messages: Record<string, any> = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
messages = {};
|
||||
}
|
||||
|
||||
@@ -91,6 +90,7 @@ export default async function Layout(props: {
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
@@ -160,6 +160,8 @@ export default async function Layout(props: {
|
||||
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
|
||||
<AutoBrochureModal />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,11 +2,12 @@ import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
@@ -278,6 +279,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
@@ -322,6 +324,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||
|
||||
const descriptionContent = {
|
||||
root: {
|
||||
...product.content.root,
|
||||
@@ -341,6 +345,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
excelPath={excelPath}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -353,29 +358,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
categories={product.frontmatter.categories}
|
||||
sku={product.frontmatter.sku}
|
||||
/>
|
||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
||||
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
{/* Background Decorative Elements */}
|
||||
<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" />
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<Link
|
||||
href={`/${locale}/${productsSlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
className="hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<Link
|
||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||
>
|
||||
{categoryTitle}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||
{product.frontmatter.title}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||
@@ -386,7 +393,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{t('englishVersion')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||
{product.frontmatter.categories.map((cat, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
@@ -397,10 +404,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Heading level={1} className="text-white mb-8 uppercase">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||
{product.frontmatter.title}
|
||||
</Heading>
|
||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -414,11 +421,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* Large Product Image Section */}
|
||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||
<div
|
||||
className="relative -mt-32 mb-32 animate-slide-up"
|
||||
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
<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-[21/9]">
|
||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
@@ -453,10 +460,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||
{/* Description Area Next to Sidebar */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
||||
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||
{descriptionChildren.length > 0 ? (
|
||||
<PayloadRichText data={descriptionContent} />
|
||||
) : product.frontmatter.description ? (
|
||||
@@ -464,6 +471,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{product.application?.root?.children?.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<PayloadRichText data={product.application} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -472,7 +485,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Full-width Technical Data Below */}
|
||||
<div className="mt-16 pt-16 border-t-0">
|
||||
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||
<PayloadRichText data={technicalContent} />
|
||||
</div>
|
||||
@@ -486,7 +499,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
<div className="flex flex-col gap-4 max-w-2xl">
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -530,7 +546,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Related Products Section */}
|
||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge
|
||||
@@ -107,13 +106,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-accent italic">{chunks}</span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
@@ -223,7 +216,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
|
||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
<span className="text-white">{t('michael.name')}</span>
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
{t('michael.quote')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
{t('klaus.quote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
114
app/actions/brochure.ts
Normal file
114
app/actions/brochure.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
const isTestSubmission = email === 'testing@mintel.me';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
@@ -84,26 +85,30 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
}),
|
||||
);
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Notification email FAILED', {
|
||||
error: notificationResult.error,
|
||||
if (!isTestSubmission) {
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
email,
|
||||
html: notificationHtml,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{ action: 'sendContactFormAction_notification', email },
|
||||
);
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
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 {
|
||||
logger.info('Skipping notification email for test submission', { email });
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
@@ -115,26 +120,30 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
}),
|
||||
);
|
||||
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Confirmation email FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
if (!isTestSubmission) {
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||
{ action: 'sendContactFormAction_confirmation', email },
|
||||
);
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
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 {
|
||||
logger.info('Skipping confirmation email for test submission', { email });
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
|
||||
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
88
components/BrochureCTA.tsx
Normal file
88
components/BrochureCTA.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||
* The full-catalog PDF is ONLY revealed after email submission.
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
254
components/BrochureModal.tsx
Normal file
254
components/BrochureModal.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Close on escape + lock scroll + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Auto-focus input when opened
|
||||
const firstInput = document.getElementById('brochure-email');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 50);
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
|
||||
if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
if (focusable.length > 0) {
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Strict overflow lock on mobile as well
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
setState('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
|
||||
>
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import FooterBrochureForm from './FooterBrochureForm';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
@@ -15,14 +16,14 @@ export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
||||
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
|
||||
<Container>
|
||||
<h2 className="sr-only">Footer Navigation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||
{/* Brand Column – full width on mobile */}
|
||||
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block group"
|
||||
@@ -67,9 +68,9 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Legal Column */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('legal')}
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
@@ -121,8 +122,9 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Company Column */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('company')}
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
@@ -189,9 +191,9 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{/* Recent Posts Column – full width on mobile */}
|
||||
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('recentPosts')}
|
||||
</h3>
|
||||
<ul className="space-y-6 list-none m-0 p-0">
|
||||
@@ -242,7 +244,11 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
||||
<div className="mb-12 md:mb-16">
|
||||
<FooterBrochureForm />
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link
|
||||
|
||||
124
components/FooterBrochureForm.tsx
Normal file
124
components/FooterBrochureForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FooterBrochureForm({ className }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success) {
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'footer_inline',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'success') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-1">
|
||||
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">
|
||||
{locale === 'de'
|
||||
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||
: 'We have just sent the catalog to your email.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||
{t('ctaTitle')}
|
||||
</h4>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||
>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
className={cn(
|
||||
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||
phase === 'loading'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{phase === 'error' && err && (
|
||||
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,8 @@ export default function Header() {
|
||||
{
|
||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -152,9 +153,7 @@ export default function Header() {
|
||||
<>
|
||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||
<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
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
@@ -336,115 +335,138 @@ export default function Header() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
inert={isMobileMenuOpen ? undefined : true}
|
||||
>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className={cn(
|
||||
'transition-all duration-500 transform',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
aria-current={
|
||||
(
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||
)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
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',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
inert={isMobileMenuOpen ? undefined : true}
|
||||
>
|
||||
{/* Close Button inside overlay */}
|
||||
<div className="flex justify-end p-6 pt-8">
|
||||
<button
|
||||
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||
aria-label={t('toggleMenu')}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className={cn(
|
||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||
'transition-all duration-500 transform',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||
>
|
||||
<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 className="w-px h-6 bg-white/30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
aria-current={
|
||||
(
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||
)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
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 className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
<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'}`}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<div
|
||||
className={cn(
|
||||
'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 className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<div
|
||||
className={cn(
|
||||
'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,119 +39,89 @@ import CTA from '@/components/home/CTA';
|
||||
const jsxConverters: JSXConverters = {
|
||||
...defaultJSXConverters,
|
||||
// Let the default converters handle text nodes to preserve valid formatting
|
||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
||||
text: ({ node }: any) => {
|
||||
const text = node.text;
|
||||
// Handle markdown-style lists embedded in text nodes from Markdown migration
|
||||
if (text && text.includes('\n- ')) {
|
||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
||||
// If first part doesn't start with "- ", it's a prefix paragraph
|
||||
const startsWithDash = text.trimStart().startsWith('- ');
|
||||
const prefix = startsWithDash ? null : parts.shift();
|
||||
return (
|
||||
<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>
|
||||
),
|
||||
paragraph: ({ node, nodesToJSX }: any) => {
|
||||
return (
|
||||
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||
heading: ({ node, children }: any) => {
|
||||
heading: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
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')
|
||||
return (
|
||||
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
|
||||
<h2
|
||||
id={id}
|
||||
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
if (tag === 'h2')
|
||||
return (
|
||||
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
|
||||
<h3
|
||||
id={id}
|
||||
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
if (tag === 'h3')
|
||||
return (
|
||||
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
|
||||
<h4
|
||||
id={id}
|
||||
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
if (tag === 'h4')
|
||||
return (
|
||||
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
|
||||
<h5
|
||||
id={id}
|
||||
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
if (tag === 'h5')
|
||||
return (
|
||||
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
|
||||
<h6
|
||||
id={id}
|
||||
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
||||
return (
|
||||
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
list: ({ node, children }: any) => {
|
||||
list: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
if (node?.listType === 'number') {
|
||||
return (
|
||||
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||
@@ -168,28 +138,33 @@ const jsxConverters: JSXConverters = {
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
listitem: ({ node, children }: any) => {
|
||||
listitem: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
if (node?.checked != null) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
||||
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.checked}
|
||||
readOnly
|
||||
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
<div className="flex-1">{children}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
||||
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||
},
|
||||
quote: ({ children }: any) => (
|
||||
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
link: ({ node, children }: any) => {
|
||||
quote: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
return (
|
||||
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
link: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
// Handling Payload CMS link nodes
|
||||
const href = node?.fields?.url || node?.url || '#';
|
||||
const newTab = node?.fields?.newTab || node?.newTab;
|
||||
@@ -1090,6 +1065,10 @@ export default function PayloadRichText({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
if (data.root?.children?.length > 0) {
|
||||
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
||||
}
|
||||
|
||||
const dynamicConverters: JSXConverters = {
|
||||
...jsxConverters,
|
||||
blocks: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -38,29 +39,47 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
{technicalItems.length > 0 && (
|
||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<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">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<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">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
@@ -72,18 +91,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
|
||||
{table.metaItems.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||
{table.metaItems.map((item, mIdx) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||
@@ -98,11 +117,12 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
)}
|
||||
|
||||
<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
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
>
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
|
||||
@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AnalyticsShell() {
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
|
||||
<Suspense fallback={null}>
|
||||
<DynamicAnalyticsProvider />
|
||||
<DynamicScrollDepthTracker />
|
||||
<DynamicWebVitalsTracker />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
54
components/analytics/WebVitalsTracker.tsx
Normal file
54
components/analytics/WebVitalsTracker.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useReportWebVitals } from 'next/web-vitals';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
|
||||
/**
|
||||
* WebVitalsTracker component.
|
||||
*
|
||||
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||
* This provides "meaningful" page speed tracking by measuring real user
|
||||
* experiences (LCP, CLS, INP, etc.).
|
||||
*/
|
||||
export default function WebVitalsTracker() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
useReportWebVitals((metric) => {
|
||||
const { name, value, id, label } = metric;
|
||||
|
||||
// Determine rating (simplified version of web-vitals standards)
|
||||
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||
|
||||
if (name === 'LCP') {
|
||||
if (value > 4000) rating = 'poor';
|
||||
else if (value > 2500) rating = 'needs-improvement';
|
||||
} else if (name === 'CLS') {
|
||||
if (value > 0.25) rating = 'poor';
|
||||
else if (value > 0.1) rating = 'needs-improvement';
|
||||
} else if (name === 'FID') {
|
||||
if (value > 300) rating = 'poor';
|
||||
else if (value > 100) rating = 'needs-improvement';
|
||||
} else if (name === 'FCP') {
|
||||
if (value > 3000) rating = 'poor';
|
||||
else if (value > 1800) rating = 'needs-improvement';
|
||||
} else if (name === 'TTFB') {
|
||||
if (value > 1500) rating = 'poor';
|
||||
else if (value > 800) rating = 'needs-improvement';
|
||||
} else if (name === 'INP') {
|
||||
if (value > 500) rating = 'poor';
|
||||
else if (value > 200) rating = 'needs-improvement';
|
||||
}
|
||||
|
||||
// Report to Umami
|
||||
trackEvent('web-vital', {
|
||||
metric: name,
|
||||
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||
rating,
|
||||
id,
|
||||
label,
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface TechnicalGridItem {
|
||||
label: string;
|
||||
@@ -18,25 +18,41 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||
<span className="relative inline-block">
|
||||
{title}
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr KLZ Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your KLZ Cable Catalog',
|
||||
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from klz-cables.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#000d26',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #4da612',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#4da612',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -20,7 +19,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<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]"
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||
>
|
||||
{data?.title ? (
|
||||
<span
|
||||
@@ -28,27 +27,19 @@ export default function Hero({ data }: { data?: any }) {
|
||||
__html: data.title
|
||||
.replace(
|
||||
/<green>/g,
|
||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||
'<span class="text-accent italic">',
|
||||
)
|
||||
.replace(
|
||||
/<\/green>/g,
|
||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||
'</span>',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
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 className="text-accent italic">
|
||||
{chunks}
|
||||
</span>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||
|
||||
@@ -74,11 +74,14 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
suppressHydrationWarning
|
||||
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
['en', 'de'].includes(locale) ? locale : 'de',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
</time>
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function VideoSection({ data }: { data?: any }) {
|
||||
@@ -41,17 +40,11 @@ export default function VideoSection({ data }: { data?: any }) {
|
||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||
{data?.title ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="italic text-accent">').replace(/<\/future>/g, '</span>') }} />
|
||||
) : (
|
||||
t.rich('title', {
|
||||
future: (chunks) => (
|
||||
<span className="relative inline-block mx-2">
|
||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
<span className="italic text-accent">{chunks}</span>
|
||||
),
|
||||
})
|
||||
)}
|
||||
|
||||
BIN
data/excel/high-voltage.xlsx
Normal file
BIN
data/excel/high-voltage.xlsx
Normal file
Binary file not shown.
BIN
data/excel/low-voltage-KM.xlsx
Normal file
BIN
data/excel/low-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/solar-cables.xlsx
Normal file
BIN
data/excel/solar-cables.xlsx
Normal file
Binary file not shown.
2480
data/processed/products.json
Normal file
2480
data/processed/products.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ services:
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
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}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
UV_THREADPOOL_SIZE: "4"
|
||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||
CI: "true"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
klz-app:
|
||||
image: registry.infra.mintel.me/mintel/klz-2026:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/klz-2026:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
default:
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
|
||||
klz-gatekeeper:
|
||||
profiles: [ "gatekeeper" ]
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||
image: git.infra.mintel.me/mmintel/gatekeeper:testing
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
infra:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||
// from being included in the initial JS bundle.
|
||||
export {};
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
35
lib/blog.ts
35
lib/blog.ts
@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; 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 '';
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from 'path';
|
||||
*/
|
||||
export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
|
||||
// Subdirectories to search in
|
||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
// List of patterns to try for the current locale
|
||||
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
|
||||
const patterns = [
|
||||
`${slug}-${locale}.pdf`,
|
||||
`${slug}-2-${locale}.pdf`,
|
||||
`${slug}-3-${locale}.pdf`,
|
||||
`${slug}-mv-${locale}.pdf`,
|
||||
`${slug}-hv-${locale}.pdf`,
|
||||
`${normalizedSlug}-${locale}.pdf`,
|
||||
`${normalizedSlug}-2-${locale}.pdf`,
|
||||
`${normalizedSlug}-3-${locale}.pdf`,
|
||||
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${slug}-en.pdf`,
|
||||
`${slug}-2-en.pdf`,
|
||||
`${slug}-3-en.pdf`,
|
||||
`${slug}-mv-en.pdf`,
|
||||
`${slug}-hv-en.pdf`,
|
||||
`${normalizedSlug}-en.pdf`,
|
||||
`${normalizedSlug}-2-en.pdf`,
|
||||
`${normalizedSlug}-3-en.pdf`,
|
||||
`${normalizedSlug}-mv-en.pdf`,
|
||||
`${normalizedSlug}-hv-en.pdf`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the datasheet Excel path for a given product slug and locale.
|
||||
* Checks public/datasheets for matching .xlsx files.
|
||||
*/
|
||||
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
const patterns = [
|
||||
`${slug}-${locale}.xlsx`,
|
||||
`${slug}-2-${locale}.xlsx`,
|
||||
`${slug}-3-${locale}.xlsx`,
|
||||
`${normalizedSlug}-${locale}.xlsx`,
|
||||
`${normalizedSlug}-2-${locale}.xlsx`,
|
||||
`${normalizedSlug}-3-${locale}.xlsx`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of patterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enPatterns = [
|
||||
`${slug}-en.xlsx`,
|
||||
`${slug}-2-en.xlsx`,
|
||||
`${slug}-3-en.xlsx`,
|
||||
`${normalizedSlug}-en.xlsx`,
|
||||
`${normalizedSlug}-2-en.xlsx`,
|
||||
`${normalizedSlug}-3-en.xlsx`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
|
||||
806
lib/pdf-brochure.tsx
Normal file
806
lib/pdf-brochure.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// ─── Brand Tokens ───────────────────────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const PAGE = { w: 595.28, h: 841.89 }; // A4 in points
|
||||
const MARGIN = 56;
|
||||
const CONTENT_W = PAGE.w - MARGIN * 2;
|
||||
const HEADER_H = 52;
|
||||
const FOOTER_H = 48;
|
||||
const BODY_TOP = HEADER_H + 40;
|
||||
const BODY_BOTTOM = FOOTER_H + 24;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BrochureProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml?: string;
|
||||
images: string[];
|
||||
featuredImage: string | null;
|
||||
sku: string;
|
||||
slug: string;
|
||||
categories: Array<{ name: string }>;
|
||||
attributes: Array<{ name: string; options: string[] }>;
|
||||
qrWebsite?: string | Buffer;
|
||||
qrDatasheet?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface BrochureProps {
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: {
|
||||
tagline: string;
|
||||
values: Array<{ title: string; description: string }>;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
};
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
|
||||
marketingSections?: Array<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
items?: Array<{ title: string; description: string }>;
|
||||
highlights?: Array<{ value: string; label: string }>;
|
||||
pullQuote?: string;
|
||||
}>;
|
||||
galleryImages?: Array<string | Buffer | undefined>;
|
||||
messages?: Record<string, any>;
|
||||
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const imgValid = (src?: string | Buffer): boolean => {
|
||||
if (!src) return false;
|
||||
if (Buffer.isBuffer(src)) return src.length > 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||
catalog: 'Kabelkatalog',
|
||||
subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.',
|
||||
about: 'Über uns',
|
||||
toc: 'Inhalt',
|
||||
overview: 'Übersicht',
|
||||
application: 'Anwendungsbereich',
|
||||
specs: 'Technische Daten',
|
||||
contact: 'Kontakt',
|
||||
qrWeb: 'Details',
|
||||
qrPdf: 'PDF',
|
||||
values: 'Unsere Werte',
|
||||
edition: 'Ausgabe',
|
||||
page: 'Seite',
|
||||
property: 'Eigenschaft',
|
||||
value: 'Wert',
|
||||
other: 'Sonstige'
|
||||
} : {
|
||||
catalog: 'Cable Catalog',
|
||||
subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.',
|
||||
about: 'About Us',
|
||||
toc: 'Contents',
|
||||
overview: 'Overview',
|
||||
application: 'Application',
|
||||
specs: 'Technical Data',
|
||||
contact: 'Contact',
|
||||
qrWeb: 'Details',
|
||||
qrPdf: 'PDF',
|
||||
values: 'Our Values',
|
||||
edition: 'Edition',
|
||||
page: 'Page',
|
||||
property: 'Property',
|
||||
value: 'Value',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
// ─── Rich Text ──────────────────────────────────────────────────────────────
|
||||
|
||||
const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => {
|
||||
const paragraphs = children.split('\n\n').filter(p => p.trim());
|
||||
return (
|
||||
<View style={{ gap }}>
|
||||
{paragraphs.map((para, pIdx) => {
|
||||
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
|
||||
let rem = para;
|
||||
while (rem.length > 0) {
|
||||
const bm = rem.match(/\*\*(.+?)\*\*/);
|
||||
const im = rem.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
|
||||
const first = [bm, im].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
|
||||
if (!first || first.index === undefined) { parts.push({ text: rem }); break; }
|
||||
if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
|
||||
parts.push({ text: first[1], bold: first[0].startsWith('**'), italic: !first[0].startsWith('**') });
|
||||
rem = rem.substring(first.index + first[0].length);
|
||||
}
|
||||
return (
|
||||
<Text key={pIdx} style={style}>
|
||||
{parts.map((part, i) => (
|
||||
<Text key={i} style={{
|
||||
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: color || C.navyDeep } : {}),
|
||||
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.green } : {}),
|
||||
}}>{part.text}</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Shared Components ──────────────────────────────────────────────────────
|
||||
|
||||
// Thin brand bar at the top of every page
|
||||
const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => (
|
||||
<View style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
|
||||
paddingHorizontal: MARGIN, paddingBottom: 12,
|
||||
}} fixed>
|
||||
{logo ? <Image src={logo} style={{ width: 56 }} /> : <Text style={{ fontSize: 14, fontWeight: 700, color: dark ? C.white : C.navy }}>KLZ</Text>}
|
||||
{right && <Text style={{ fontSize: 7, fontWeight: 700, color: dark ? C.gray400 : C.gray400, letterSpacing: 1.2, textTransform: 'uppercase' }}>{right}</Text>}
|
||||
</View>
|
||||
);
|
||||
|
||||
const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => (
|
||||
<View style={{
|
||||
position: 'absolute', bottom: 20, left: MARGIN, right: MARGIN,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderTopWidth: 0.5, borderTopColor: dark ? 'rgba(255,255,255,0.15)' : C.gray200, borderTopStyle: 'solid',
|
||||
paddingTop: 8,
|
||||
}} fixed>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{left}</Text>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{right}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Green accent bar
|
||||
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
|
||||
|
||||
// ─── FadeImage ─────────────────────────────────────────────────────────────
|
||||
// Simulates a gradient fade at one edge using stacked opacity bands.
|
||||
// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles.
|
||||
//
|
||||
// 'position' param: which edge fades INTO the page background
|
||||
// 'bottom' → image visible at top, fades down into bgColor
|
||||
// 'top' → image visible at bottom, fades up into bgColor
|
||||
// 'right' → image on left side, fades right into bgColor
|
||||
//
|
||||
// The component must be placed ABSOLUTE (position: 'absolute') on the page.
|
||||
|
||||
const FadeImage: React.FC<{
|
||||
src: string | Buffer;
|
||||
top?: number; left?: number; right?: number; bottom?: number;
|
||||
width: number | string;
|
||||
height: number;
|
||||
fadeEdge: 'bottom' | 'top' | 'right' | 'left';
|
||||
fadeSize?: number; // how many points the fade spans
|
||||
bgColor: string;
|
||||
opacity?: number; // overall image darkness (0–1, applied via overlay)
|
||||
}> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => {
|
||||
const STEPS = 40; // High number of overlapping bands
|
||||
|
||||
const bands = Array.from({ length: STEPS }, (_, i) => {
|
||||
// i=0 is the widest band reaching deepest into the image.
|
||||
// i=STEPS-1 is the narrowest band right at the fade edge.
|
||||
// Because they all anchor at the edge and overlap, their opacity compounds.
|
||||
// We use an ease-in curve for distance to make the fade look natural.
|
||||
const t = 1.0 / STEPS;
|
||||
const easeDist = Math.pow((i + 1) / STEPS, 1.2);
|
||||
const dist = fadeSize * easeDist;
|
||||
|
||||
const style: any = {
|
||||
position: 'absolute',
|
||||
backgroundColor: bgColor,
|
||||
opacity: t,
|
||||
};
|
||||
|
||||
// All bands anchor at the fade edge and extend inward by `dist`
|
||||
if (fadeEdge === 'bottom') {
|
||||
Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 });
|
||||
} else if (fadeEdge === 'top') {
|
||||
Object.assign(style, { left: 0, right: 0, height: dist, top: 0 });
|
||||
} else if (fadeEdge === 'right') {
|
||||
Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 });
|
||||
} else {
|
||||
Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 });
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ position: 'absolute', top, left, right, bottom, width, height }}>
|
||||
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{/* Overlay using bgColor to "wash out" / dilute the image */}
|
||||
{opacity > 0 && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: bgColor, opacity }} />}
|
||||
{/* Gradient fade bands */}
|
||||
{bands.map((s, i) => <View key={i} style={s} />)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAGE 1: COVER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const CoverPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
introContent?: BrochureProps['introContent'];
|
||||
logoWhite?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer | undefined>;
|
||||
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
||||
const l = labels(locale);
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
||||
const bg = galleryImages?.[0] || introContent?.heroImage;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||
{/* Full-page background image with dark overlay */}
|
||||
{imgValid(bg) && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={bg!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.82 }} />
|
||||
</View>
|
||||
)}
|
||||
{!imgValid(bg) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||
|
||||
{/* Vertical accent stripe */}
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, width: 5, height: '40%', backgroundColor: C.green }} />
|
||||
|
||||
{/* Logo top-left */}
|
||||
<View style={{ position: 'absolute', top: 56, left: MARGIN }}>
|
||||
{imgValid(logoWhite) ? <Image src={logoWhite!} style={{ width: 120 }} /> : <Text style={{ fontSize: 24, fontWeight: 700, color: C.white }}>KLZ</Text>}
|
||||
</View>
|
||||
|
||||
{/* Main title block — bottom third of page */}
|
||||
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
|
||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
|
||||
{l.catalog}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: C.gray300, lineHeight: 1.8, marginTop: 16, maxWidth: 340 }}>
|
||||
{introContent?.excerpt || l.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View style={{ position: 'absolute', bottom: 40, left: MARGIN, right: MARGIN, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 8, color: C.gray400, letterSpacing: 1, textTransform: 'uppercase' }}>{l.edition} {dateStr}</Text>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.green }}>www.klz-cables.com</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAGES 2–N: INFO PAGES (each marketing section = own page)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const InfoPage: React.FC<{
|
||||
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||
image?: string | Buffer;
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
dark?: boolean;
|
||||
imagePosition?: 'top' | 'bottom-half';
|
||||
}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => {
|
||||
const bg = dark ? C.navyDeep : C.white;
|
||||
const textColor = dark ? C.gray300 : C.gray600;
|
||||
const titleColor = dark ? C.white : C.navyDeep;
|
||||
const boldColor = dark ? C.white : C.navyDeep;
|
||||
const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack;
|
||||
|
||||
// Image at top: 240pt tall, content starts below via paddingTop
|
||||
const IMG_TOP_H = 240;
|
||||
const bodyTopWithImg = imagePosition === 'top' && imgValid(image)
|
||||
? IMG_TOP_H + 24 // content starts below image
|
||||
: BODY_TOP;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
{/* Absolute image — from page edge, fades into bg */}
|
||||
{imgValid(image) && imagePosition === 'top' && (
|
||||
<FadeImage
|
||||
src={image!}
|
||||
top={0} left={0} right={0}
|
||||
width="100%" height={IMG_TOP_H}
|
||||
fadeEdge="bottom" fadeSize={120}
|
||||
bgColor={bg}
|
||||
opacity={dark ? 0.85 : 0.9} // EXTREMELY high opacity of bgColor to make image incredibly subtle
|
||||
/>
|
||||
)}
|
||||
{imgValid(image) && imagePosition === 'bottom-half' && (
|
||||
<FadeImage
|
||||
src={image!}
|
||||
bottom={FOOTER_H + 20} left={0} right={0}
|
||||
width="100%" height={340}
|
||||
fadeEdge="top" fadeSize={140}
|
||||
bgColor={bg}
|
||||
opacity={dark ? 0.85 : 0.9} // Extremely subtle
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header — on top of image */}
|
||||
<Header logo={headerLogo} right="KLZ Cables" dark={dark} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
|
||||
|
||||
{/* Content — pushed below image when top-position */}
|
||||
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||
|
||||
{/* Label + Title */}
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 16 }}>{section.title}</Text>
|
||||
|
||||
{/* Description */}
|
||||
{section.description && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<RichText style={{ fontSize: 10, color: textColor, lineHeight: 1.7 }} gap={8} color={boldColor}>
|
||||
{section.description}
|
||||
</RichText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{section.highlights && section.highlights.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||
{section.highlights.map((h, i) => (
|
||||
<View key={i} style={{
|
||||
flex: 1,
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingVertical: 12, paddingHorizontal: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pull quote */}
|
||||
{section.pullQuote && (
|
||||
<View style={{
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||
}}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||
„{section.pullQuote}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Items — 2-column grid */}
|
||||
{section.items && section.items.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{section.items.map((item, i) => (
|
||||
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
||||
<View style={{ width: 20, height: 1.5, backgroundColor: dark ? 'rgba(255,255,255,0.2)' : C.gray300, marginBottom: 6 }} />
|
||||
<RichText style={{ fontSize: 8.5, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||
{item.description}
|
||||
</RichText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// About page (first info page)
|
||||
const AboutPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
logoBlack?: string | Buffer;
|
||||
image?: string | Buffer;
|
||||
messages?: Record<string, any>;
|
||||
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
// Image at top: 200pt tall (smaller to leave more room for content)
|
||||
const IMG_TOP_H = 200;
|
||||
const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
|
||||
|
||||
// Pull directors content from messages if available
|
||||
const team = messages?.Team || {};
|
||||
const michael = team.michael;
|
||||
const klaus = team.klaus;
|
||||
const legacy = team.legacy;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
{/* Top-aligned image fading into white bottom */}
|
||||
{imgValid(image) && (
|
||||
<FadeImage
|
||||
src={image!}
|
||||
top={0} left={0} right={0}
|
||||
width="100%" height={IMG_TOP_H}
|
||||
fadeEdge="bottom" fadeSize={140}
|
||||
bgColor={C.white}
|
||||
opacity={0.92}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Header logo={logoBlack} right="KLZ Cables" />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Content pushed below the fading image */}
|
||||
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||
<Text style={{ fontSize: 22, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 6 }}>KLZ Cables</Text>
|
||||
<AccentBar />
|
||||
|
||||
<RichText style={{ fontSize: 10, color: C.gray900, lineHeight: 1.8 }} gap={8}>
|
||||
{companyInfo.tagline}
|
||||
</RichText>
|
||||
|
||||
{/* Company mission — makes immediately clear what KLZ does */}
|
||||
<View style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
|
||||
{locale === 'de'
|
||||
? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.'
|
||||
: 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'
|
||||
}
|
||||
</RichText>
|
||||
</View>
|
||||
|
||||
{/* Directors — two-column */}
|
||||
{(michael || klaus) && (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>
|
||||
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||
{[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
|
||||
<View key={i} style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
{p.photo && (
|
||||
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||
)}
|
||||
<View>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{p.data.description}</Text>
|
||||
{p.data.quote && (
|
||||
<View style={{ borderLeftWidth: 2, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 8 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||
„{p.data.quote}“
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Values grid */}
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.values}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
|
||||
{companyInfo.values.map((v, i) => (
|
||||
<View key={i} style={{ width: '46%', marginBottom: 4 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<View style={{ width: 20, height: 20, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.5, paddingLeft: 28 }}>{v.description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TOC PAGE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TocPage: React.FC<{
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
productStartPage: number;
|
||||
}> = ({ products, locale, logoBlack, productStartPage }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
// Group products by their first category
|
||||
const categories: Array<{ name: string; products: Array<BrochureProduct & { startingPage: number }> }> = [];
|
||||
let currentPageNum = productStartPage;
|
||||
for (const p of products) {
|
||||
const catName = p.categories[0]?.name || l.other;
|
||||
let category = categories.find(c => c.name === catName);
|
||||
if (!category) {
|
||||
category = { name: catName, products: [] };
|
||||
categories.push(category);
|
||||
}
|
||||
category.products.push({ ...p, startingPage: currentPageNum });
|
||||
currentPageNum++;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right={l.overview} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
<View style={{ paddingTop: BODY_TOP + 40 }}>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
|
||||
{l.catalog}
|
||||
</Text>
|
||||
|
||||
{categories.map((cat, i) => (
|
||||
<View key={i} style={{ marginBottom: 16 }} minPresenceAhead={40}>
|
||||
{cat.name && (
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: 'column', gap: 6 }}>
|
||||
{cat.products.map((p, j) => (
|
||||
<View key={j} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{p.name}</Text>
|
||||
<View style={{ flex: 1, borderBottomWidth: 1, borderBottomColor: C.gray200, borderBottomStyle: 'dotted', marginHorizontal: 8, marginBottom: 3 }} />
|
||||
<Text style={{ fontSize: 9, color: C.gray600 }}>{(p.startingPage || 0).toString().padStart(2, '0')}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PRODUCT PAGES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ProductPage: React.FC<{
|
||||
product: BrochureProduct;
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
}> = ({ product, locale, logoBlack }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right="KLZ Cables" />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Product image block reduced strictly to 110pt high */}
|
||||
{product.featuredImage && (
|
||||
<View style={{ height: 110, marginBottom: 20, marginHorizontal: -MARGIN }}>
|
||||
<Image src={product.featuredImage as unknown as Buffer} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Labels & Name */}
|
||||
{product.categories.length > 0 && (
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||
{product.categories.map(c => c.name).join(' • ')}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ fontSize: 20, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 16 }}>{product.name}</Text>
|
||||
|
||||
{/* Description — full width */}
|
||||
{product.descriptionHtml && (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 6 }}>{l.application}</Text>
|
||||
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6 }} gap={6}>
|
||||
{product.descriptionHtml}
|
||||
</RichText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical Data — full-width striped table */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
|
||||
|
||||
{/* Table header */}
|
||||
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 5, paddingHorizontal: 10, marginBottom: 2 }}>
|
||||
<View style={{ width: '50%' }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{product.attributes.map((attr, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5, paddingHorizontal: 10,
|
||||
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
||||
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<View style={{ width: '50%', paddingRight: 12 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 8, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* QR Codes — horizontal row at bottom */}
|
||||
{(product.qrWebsite || product.qrDatasheet) && (
|
||||
<View style={{ flexDirection: 'row', gap: 24, marginTop: 8 }}>
|
||||
{product.qrWebsite && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||
<Image src={product.qrWebsite} style={{ width: 36, height: 36 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrWeb}</Text>
|
||||
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{product.qrDatasheet && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||
<Image src={product.qrDatasheet} style={{ width: 36, height: 36 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrPdf}</Text>
|
||||
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BACK COVER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BackCover: React.FC<{
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
locale: 'en' | 'de';
|
||||
logoWhite?: string | Buffer;
|
||||
image?: string | Buffer;
|
||||
}> = ({ companyInfo, locale, logoWhite, image }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||
{/* Background */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.92 }} />
|
||||
</View>
|
||||
)}
|
||||
{!imgValid(image) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: MARGIN }}>
|
||||
{imgValid(logoWhite) ? (
|
||||
<Image src={logoWhite!} style={{ width: 160, marginBottom: 40 }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: C.white, letterSpacing: 3, textTransform: 'uppercase', marginBottom: 40 }}>KLZ CABLES</Text>
|
||||
)}
|
||||
|
||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 40 }} />
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.contact}</Text>
|
||||
<Text style={{ fontSize: 12, color: C.white, lineHeight: 1.8, textAlign: 'center', marginBottom: 20 }}>{companyInfo.address}</Text>
|
||||
|
||||
<Text style={{ fontSize: 12, color: C.white, marginBottom: 4 }}>{companyInfo.phone}</Text>
|
||||
<Text style={{ fontSize: 12, color: C.gray300, marginBottom: 24 }}>{companyInfo.email}</Text>
|
||||
|
||||
<Text style={{ fontSize: 13, fontWeight: 700, color: C.green }}>{companyInfo.website}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ position: 'absolute', bottom: 28, left: MARGIN, right: MARGIN, alignItems: 'center' }} fixed>
|
||||
<Text style={{ fontSize: 8, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DOCUMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||
}) => {
|
||||
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
|
||||
const numInfoPages = 1 + (marketingSections?.length || 0);
|
||||
const productStartPage = 1 + numInfoPages + 1;
|
||||
|
||||
// Image assignment — each page gets a UNIQUE image, never repeating
|
||||
// galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover
|
||||
// TOC intentionally gets NO image (clean list page)
|
||||
const totalGallery = galleryImages?.length || 0;
|
||||
const backCoverImgIdx = totalGallery - 1;
|
||||
|
||||
// Section themes: alternate light/dark
|
||||
const sectionThemes: Array<'light' | 'dark'> = [];
|
||||
// imagePosition: alternate between top and bottom-half for variety
|
||||
const imagePositions: Array<'top' | 'bottom-half'> = [];
|
||||
if (marketingSections) {
|
||||
for (let i = 0; i < marketingSections.length; i++) {
|
||||
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
|
||||
imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||
|
||||
{/* About page — image[1] */}
|
||||
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} directorPhotos={directorPhotos} />
|
||||
|
||||
{/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */}
|
||||
{marketingSections?.map((section, i) => (
|
||||
<InfoPage
|
||||
key={`info-${i}`}
|
||||
section={section}
|
||||
image={galleryImages?.[i + 2]}
|
||||
logoBlack={logoBlack}
|
||||
logoWhite={logoWhite}
|
||||
dark={sectionThemes[i] === 'dark'}
|
||||
imagePosition={imagePositions[i]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* TOC — no decorative image, clean list */}
|
||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} />
|
||||
|
||||
{/* Products — each on its own page */}
|
||||
{products.map(p => (
|
||||
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
||||
))}
|
||||
|
||||
{/* Back cover — last gallery image */}
|
||||
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
@@ -18,27 +10,43 @@ Font.register({
|
||||
],
|
||||
});
|
||||
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
// ─── Brand Tokens (matching brochure) ────────────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: '#111827', // Text Primary
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
header: {
|
||||
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 24,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
letterSpacing: 1,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 10,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#001a4d',
|
||||
color: C.green,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderColor: C.gray200,
|
||||
backgroundColor: C.white,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
productMeta: {
|
||||
fontSize: 10,
|
||||
color: '#4b5563',
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
|
||||
|
||||
noImage: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
color: C.gray400,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
// Content sections
|
||||
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26', // Primary Dark
|
||||
marginBottom: 8,
|
||||
color: C.green,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.2,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: '#82ed20', // Accent Green
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 8,
|
||||
borderRadius: 1.5,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
description: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.7,
|
||||
color: '#4b5563', // Text Secondary
|
||||
color: C.gray600,
|
||||
},
|
||||
|
||||
// Technical data table
|
||||
specsTable: {
|
||||
marginTop: 8,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
specsTableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableRowLast: {
|
||||
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
|
||||
|
||||
specsTableLabelCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: C.offWhite,
|
||||
borderRightWidth: 0.5,
|
||||
borderRightColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableValueCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
|
||||
specsTableLabelText: {
|
||||
fontSize: 9,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
specsTableValueText: {
|
||||
fontSize: 10,
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
fontSize: 9,
|
||||
color: C.gray900,
|
||||
fontWeight: 400,
|
||||
},
|
||||
|
||||
// Categories
|
||||
categories: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
categoryTag: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 100,
|
||||
backgroundColor: C.offWhite,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: C.gray200,
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
categoryText: {
|
||||
fontSize: 8,
|
||||
color: '#4b5563',
|
||||
fontSize: 7,
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Footer
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: 72,
|
||||
right: 72,
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 500,
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
{cat.name}
|
||||
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
{stripHtml(
|
||||
product.applicationHtml ||
|
||||
product.shortDescriptionHtml ||
|
||||
product.descriptionHtml,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ProductData {
|
||||
slug: string;
|
||||
frontmatter: ProductFrontmatter;
|
||||
content: any; // Lexical AST from Payload
|
||||
application?: any; // Lexical AST for Application field
|
||||
}
|
||||
|
||||
export async function getProductMetadata(
|
||||
@@ -113,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
: 50,
|
||||
},
|
||||
content: doc.content,
|
||||
application: doc.application,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +197,7 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
||||
: 50,
|
||||
},
|
||||
content: null,
|
||||
application: null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
|
||||
}
|
||||
|
||||
const errors = config.errors.glitchtip.enabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
? new GlitchtipErrorReportingService(
|
||||
{
|
||||
enabled: true,
|
||||
dsn: config.errors.glitchtip.dsn,
|
||||
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
|
||||
},
|
||||
logger,
|
||||
notifications,
|
||||
)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
|
||||
@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
|
||||
|
||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||
const errors = sentryEnabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
? new GlitchtipErrorReportingService(
|
||||
{
|
||||
enabled: true,
|
||||
dsn: config.errors.glitchtip.dsn,
|
||||
tracesSampleRate: 0.1, // Default to 10% sampling
|
||||
},
|
||||
logger,
|
||||
notifications,
|
||||
)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (sentryEnabled) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
export type GlitchtipErrorReportingServiceOptions = {
|
||||
enabled: boolean;
|
||||
dsn?: string;
|
||||
tracesSampleRate?: number;
|
||||
};
|
||||
|
||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
if (!this.sentryPromise) {
|
||||
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
||||
if (typeof window !== 'undefined' && this.options.enabled) {
|
||||
Sentry.init({
|
||||
dsn: 'https://public@errors.infra.mintel.me/1',
|
||||
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
|
||||
tunnel: '/errors/api/relay',
|
||||
enabled: true,
|
||||
tracesSampleRate: 0,
|
||||
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
});
|
||||
|
||||
106
lib/utils/technical.ts
Normal file
106
lib/utils/technical.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility for formatting technical data values.
|
||||
* Handles long lists of standards and simplifies repetitive strings.
|
||||
*/
|
||||
|
||||
export interface FormattedTechnicalValue {
|
||||
original: string;
|
||||
isList: boolean;
|
||||
parts: string[];
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a technical value string.
|
||||
* Detects if it's a list (separated by / or ,) and tries to clean it up.
|
||||
*/
|
||||
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
|
||||
if (!value) {
|
||||
return { original: '', isList: false, parts: [], displayValue: '' };
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
|
||||
// Detect list separators
|
||||
let parts: string[] = [];
|
||||
if (str.includes(' / ')) {
|
||||
parts = str.split(' / ').map(p => p.trim());
|
||||
} else if (str.includes(' /')) {
|
||||
parts = str.split(' /').map(p => p.trim());
|
||||
} else if (str.includes('/ ')) {
|
||||
parts = str.split('/ ').map(p => p.trim());
|
||||
} else if (str.split('/').length > 2) {
|
||||
// Check if it's actually many standards separated by / without spaces
|
||||
// e.g. EN123/EN456/EN789
|
||||
const split = str.split('/');
|
||||
if (split.length > 3) {
|
||||
parts = split.map(p => p.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// If no parts found yet, try comma
|
||||
if (parts.length === 0 && str.includes(', ')) {
|
||||
parts = str.split(', ').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Filter out empty parts
|
||||
parts = parts.filter(Boolean);
|
||||
|
||||
// If we have parts, let's see if we can simplify them
|
||||
if (parts.length > 2) {
|
||||
// Find common prefix to condense repetitive standards
|
||||
let commonPrefix = '';
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
let i = 0;
|
||||
while (i < first.length && first.charAt(i) === last.charAt(i)) {
|
||||
i++;
|
||||
}
|
||||
commonPrefix = first.substring(0, i);
|
||||
|
||||
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
|
||||
if (commonPrefix.length > 4) {
|
||||
// Trim trailing spaces/dashes before comparing words
|
||||
const basePrefix = commonPrefix.trim();
|
||||
const suffixParts: string[] = [];
|
||||
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
if (idx === 0) {
|
||||
suffixParts.push(parts[idx]);
|
||||
} else {
|
||||
const suffix = parts[idx].substring(commonPrefix.length).trim();
|
||||
if (suffix) {
|
||||
suffixParts.push(suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
|
||||
// Wait, returning a single string might still wrap badly.
|
||||
// Instead, we return them as chunks or just a condensed string.
|
||||
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false, // Turn off badge rendering to use text block instead
|
||||
parts: [condensedString],
|
||||
displayValue: condensedString
|
||||
};
|
||||
}
|
||||
|
||||
// If no common prefix, return as list so UI can render badges
|
||||
return {
|
||||
original: str,
|
||||
isList: true,
|
||||
parts,
|
||||
displayValue: parts.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false,
|
||||
parts: [str],
|
||||
displayValue: str
|
||||
};
|
||||
}
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||
"downloadDatasheet": "Datenblatt herunterladen",
|
||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
||||
"downloadExcel": "Excel herunterladen",
|
||||
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
|
||||
"downloadBrochure": "Produktbroschüre",
|
||||
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
|
||||
"form": {
|
||||
"contactInfo": "Kontaktinformationen",
|
||||
"projectDetails": "Projektdetails",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Produktkatalog",
|
||||
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
|
||||
"emailPlaceholder": "ihre@email.de",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"submit": "Broschüre erhalten",
|
||||
"submitting": "Wird gesendet...",
|
||||
"successTitle": "Ihre Broschüre ist bereit!",
|
||||
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
|
||||
"download": "Broschüre herunterladen",
|
||||
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||
"close": "Schließen",
|
||||
"ctaTitle": "Kompletter Produktkatalog",
|
||||
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
|
||||
"ctaButton": "Kostenlose Broschüre erhalten"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||
"downloadDatasheet": "Download Datasheet",
|
||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
||||
"downloadExcel": "Download Excel",
|
||||
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
|
||||
"downloadBrochure": "Product Brochure",
|
||||
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
|
||||
"form": {
|
||||
"contactInfo": "Contact Information",
|
||||
"projectDetails": "Project Details",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Product Catalog",
|
||||
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"emailLabel": "Email Address",
|
||||
"submit": "Get Brochure",
|
||||
"submitting": "Sending...",
|
||||
"successTitle": "Your brochure is ready!",
|
||||
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
|
||||
"download": "Download Brochure",
|
||||
"privacyNote": "By submitting you agree to our privacy policy.",
|
||||
"close": "Close",
|
||||
"ctaTitle": "Complete Product Catalog",
|
||||
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
|
||||
"ctaButton": "Get Free Brochure"
|
||||
}
|
||||
}
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -17,6 +17,7 @@ const nextConfig = {
|
||||
workerThreads: false,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
logging: {
|
||||
fetches: {
|
||||
@@ -78,7 +79,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -392,6 +393,7 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
images: {
|
||||
qualities: [75, 100],
|
||||
formats: ['image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
remotePatterns: [
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||
"cms:migrate": "payload migrate",
|
||||
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||
@@ -139,7 +141,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.0.2",
|
||||
"version": "2.2.11",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
@@ -161,4 +163,4 @@
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.563.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,9 @@ export interface Config {
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-locked-documents':
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
@@ -249,7 +251,7 @@ export interface FormSubmission {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
type: 'contact' | 'product_quote';
|
||||
type: 'contact' | 'product_quote' | 'brochure_download';
|
||||
/**
|
||||
* The specific KLZ product the user requested a quote for.
|
||||
*/
|
||||
@@ -957,7 +959,6 @@ export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -1692,7 +1692,7 @@ packages:
|
||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||
|
||||
'@mintel/eslint-config@1.8.21':
|
||||
resolution: {integrity: sha512-PsPxQk3fsUGLwQCVvHaiNNt0WcjwU/eU9xMxEGGm4SCcPw/ED4UZbaCEYwR78lp9BBGAKSqTFMWBhXfY4PjU8g==}
|
||||
resolution: {integrity: sha512-GH5tm1y89AhD+Lxf95BGCOdy7Nv1OPNLWrUpaTR6jsuKfH2dm9fU66LF7sDH5THmrkfAZ8zSzHJsKPjintv3IA==}
|
||||
|
||||
'@mintel/mail@1.8.21':
|
||||
resolution: {integrity: sha512-leZV9gINmxD4eVJ3Ij9KdrQoyib67NVHgL/93J7KcWSUWKbr2HVuKUBpiWImeeEZn3JO0f7JwRbVUzXPBRVeQA==}
|
||||
@@ -1701,10 +1701,10 @@ packages:
|
||||
react-dom: ^19.0.0
|
||||
|
||||
'@mintel/next-config@1.8.21':
|
||||
resolution: {integrity: sha512-Nwnp32h+eAjZwY9YHXHo2eIWkGrNWAqF6vT8RvyeehU1uJtoajrEpBIQPAf5dWmWSWkIdPSu9vlzEUORu39pBA==}
|
||||
resolution: {integrity: sha512-K4jb9Glf84a212BRZ/zmOUueBphmsikvStFCuDc5lxyFT+Hkj4w8ChmtI7gaUxHMrftooduGPXJ1+NFpKkvc/Q==}
|
||||
|
||||
'@mintel/next-feedback@1.8.21':
|
||||
resolution: {integrity: sha512-7WUpX/GMUBO+DYrnCm1Xb3mRQAaWDDaA1DgwavlV2m0lYiwqlPsLGafsBOY9MdGrTFxp2oFuz8lUK8/fkB2/SQ==}
|
||||
resolution: {integrity: sha512-n2KzGDbOvAskuzjbt8h5EOMSEnISxHrsXxJwDdMxCXEgmzfJSvWpP2mAqb684dimOwo1UWHE6DMSAFc1FXeYwg==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
react-dom: ^19.0.0
|
||||
@@ -1713,7 +1713,7 @@ packages:
|
||||
resolution: {integrity: sha512-sr0yDtySGou+3DNvrqY6HWSHCiVIc8nnoRbckyPMSE21AGxk2aJineXGy9BO9tulSBdhStm2SgdC7McMFTszug==}
|
||||
|
||||
'@mintel/tsconfig@1.8.21':
|
||||
resolution: {integrity: sha512-V5sY+sZlUv7i5OTqoLph+k7s0hMOzE8G7kB1snFGVuhE71zc8ooi+0WDeP++lwJz3xlFLhQTv/iRznfBnYOCew==}
|
||||
resolution: {integrity: sha512-ePBfBZiijyXKOS6nLIyxkg7QDZEEC1TugzNhmvwwpc0Yh7BmVHyNpvjg6zKsoGj2rok+9Kc8mLH1WihQIs8SKg==}
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
Binary file not shown.
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-de.pdf
Normal file
BIN
public/datasheets/n2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-en.pdf
Normal file
BIN
public/datasheets/n2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-de.pdf
Normal file
BIN
public/datasheets/n2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-en.pdf
Normal file
BIN
public/datasheets/n2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-de.pdf
Normal file
BIN
public/datasheets/n2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-en.pdf
Normal file
BIN
public/datasheets/n2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-de.pdf
Normal file
BIN
public/datasheets/na2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-en.pdf
Normal file
BIN
public/datasheets/na2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-de.pdf
Normal file
BIN
public/datasheets/na2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-en.pdf
Normal file
BIN
public/datasheets/na2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-de.pdf
Normal file
BIN
public/datasheets/na2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-en.pdf
Normal file
BIN
public/datasheets/na2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-de.pdf
Normal file
BIN
public/datasheets/nay2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-en.pdf
Normal file
BIN
public/datasheets/nay2y-en.pdf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user