Compare commits

..

13 Commits

Author SHA1 Message Date
3a61d01384 feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m2s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 23:14:25 +01:00
17ebde407e feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m0s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 22:35:49 +01:00
56cd1fb1ba fix(blog): restore table of contents, fix list item styling, resolve dynamic og image generation 2026-03-01 13:12:07 +01:00
437dd35c9c feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m20s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-01 13:07:13 +01:00
0cb96dfbac feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 10:19:13 +01:00
ec227d614f feat: implement Umami page speed tracking via Web Vitals
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m55s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 45s
- Add WebVitalsTracker component using useReportWebVitals
- Report LCP, CLS, FID, FCP, TTFB, and INP as Umami events
- Include rating (good/needs-improvement/poor) for meaningful metrics
2026-02-28 19:35:06 +01:00
cb07b739b8 fix: glitchtip performance metrics + cleanup test submissions
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Refactor GlitchtipErrorReportingService to support dynamic DSN and tracesSampleRate
- Enable client-side performance tracing by setting tracesSampleRate: 0.1
- Configure production Mail variables and restart containers on alpha.mintel.me
2026-02-28 19:33:14 +01:00
55e9531698 fix: glitchtip errors (locale, email) + E2E submission cleanup
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Add fallback 'de' locale to toLocaleDateString() to prevent RangeError
- Skip sending emails for submissions from 'testing@mintel.me'
- Update check-forms.ts to automatically delete test submissions via Payload API
- (Manual) Configured MAIL_FROM and MAIL_RECIPIENTS on alpha.mintel.me
2026-02-28 19:31:36 +01:00
089ce13c59 fix: mobile nav close button + CI Gatekeeper auth
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 4m56s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m3s
Build & Deploy / 🔔 Notify (push) Successful in 3s
- Add explicit close (×) button inside mobile nav overlay
  - Was unreachable because header's hamburger was behind overlay z-index
  - New button lives inside the overlay at full z-index visibility
- Fix check-forms.ts: authenticate via Gatekeeper login form
  - Old approach: inject raw password as session cookie (didn't work)
  - New approach: navigate to protected page, detect Gatekeeper gate,
    fill password form and submit to get a real server-signed session cookie
  - Fixes E2E form tests that failed because pages returned Gatekeeper HTML
2026-02-28 19:25:53 +01:00
a2cf9791ae fix: optimize footer layout for mobile
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🏗️ Build (push) Successful in 4m5s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m16s
Build & Deploy / 🔔 Notify (push) Successful in 4s
- Switch to grid-cols-2 on mobile (was grid-cols-1)
- Brand column: col-span-2 (full width on mobile)
- Legal + Company columns: col-span-1 each (side-by-side on mobile)
- Recent Posts column: col-span-2 (full width on mobile)
- Reduce footer padding: py-14 md:py-24 (was py-24)
- Tighten grid gap: gap-10 md:gap-16 (was gap-16)
- Bottom bar: flex-row always so copyright + language on one line
2026-02-28 10:53:00 +01:00
aa4e3aab4f fix: product texts, mobile nav background, mobile product page layout
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Fix PayloadRichText: migrate custom JSX converters to Lexical v3 nodesToJSX API
  - paragraph, heading, list, listitem, quote, link converters now use nodesToJSX
  - Resolves missing product texts since PayloadCMS migration
- Fix mobile navigation: move overlay outside <header> to prevent fixed-position clipping
  - Header transform/backdrop-filter was containing the fixed overlay
  - Use bg-primary/95 backdrop-blur-3xl for premium blue background
- Fix product image mobile layout: use md:-mt-32 responsive prefix
  - Negative margin only applies on md+ to avoid overlap on mobile
- Improve mobile product page UX:
  - Breadcrumbs: flex-wrap, truncate, reduced separator spacing
  - Hero: reduced top padding pt-28 on mobile
  - Product image card: 4/3 aspect ratio and smaller padding on mobile
  - Section spacing: use responsive md: prefixes throughout
  - Data tables: 2-col grid on mobile, smaller card padding/radius
  - Tables: add right-edge scroll hint gradient on mobile
2026-02-28 10:51:58 +01:00
ce719a1d70 chore(deps): inject missing gitea checksums for @mintel/next-config and @mintel/tsconfig
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m22s
Build & Deploy / 🏗️ Build (push) Successful in 3m26s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m48s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / call-qa-workflow (push) Failing after 47s
2026-02-27 18:58:57 +01:00
bd2f92125b chore(deps): inject correct gitea checksums for @mintel packages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 2m4s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 18:54:23 +01:00
110 changed files with 3809 additions and 364 deletions

1
.env
View File

@@ -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

4
.npmrc
View File

@@ -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}

View File

@@ -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,

View File

@@ -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>

View File

@@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
<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',

View File

@@ -91,6 +91,7 @@ export default async function Layout(props: {
'Home',
'Error',
'StandardPage',
'Brochure',
];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {

View File

@@ -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}

78
app/actions/brochure.ts Normal file
View File

@@ -0,0 +1,78 @@
'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. Track success
services.analytics.track('brochure-request-success', {
locale,
});
// Return the brochure URL
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`;
return { success: true, brochureUrl };
}

View File

@@ -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)

253
components/BrochureCTA.tsx Normal file
View File

@@ -0,0 +1,253 @@
'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 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 locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
const [url, setUrl] = useState('');
const [err, setErr] = useState('');
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); };
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open]);
function openModal() { setOpen(true); }
function closeModal() {
setOpen(false);
setPhase('form');
setUrl('');
setErr('');
}
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 && res.brochureUrl) {
setUrl(res.brochureUrl);
setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setErr(res.error || 'Error');
setPhase('error');
}
} catch {
setErr('Network error');
setPhase('error');
}
}
// ── Trigger Button ─────────────────────────────────────────────────
const trigger = (
<div className={cn(className)}>
<button
type="button"
onClick={openModal}
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>
);
// ── Modal ──────────────────────────────────────────────────────────
const modal = mounted && open ? createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
{/* Backdrop */}
<div
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
onClick={closeModal}
/>
{/* Panel */}
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}>
{/* Green top bar */}
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
{/* Close */}
<button
type="button"
onClick={closeModal}
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }}
aria-label={t('close')}
>
<svg width="14" height="14" 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 style={{ padding: '2rem' }}>
{/* Header */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
<svg width="20" height="20" fill="none" stroke="#82ed20" 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 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
{t('title')}
</h2>
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
{t('subtitle')}
</p>
</div>
{phase === 'success' ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}>
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
</div>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
>
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('download')}
</a>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
{t('emailLabel')}
</label>
<input
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
/>
{phase === 'error' && err && (
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
)}
<button
type="submit"
disabled={phase === 'loading'}
style={{
width: '100%',
padding: '1rem',
borderRadius: '1rem',
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
fontWeight: 900,
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
border: 'none',
cursor: phase === 'loading' ? 'wait' : 'pointer',
marginBottom: '0.75rem',
}}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>,
document.body,
) : null;
return (
<>
{trigger}
{modal}
</>
);
}

View File

@@ -0,0 +1,211 @@
'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 [mounted, setMounted] = useState(false);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState('');
// Mount guard for SSR/portal
useEffect(() => {
setMounted(true);
}, []);
// Close on escape + lock scroll
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleEsc);
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 && result.brochureUrl) {
setState('success');
setBrochureUrl(result.brochureUrl);
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');
setBrochureUrl(null);
setErrorMsg('');
onClose();
};
if (!mounted || !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 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"
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' && brochureUrl ? (
<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]">{t('successTitle')}</p>
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
</div>
</div>
<a
href={brochureUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('download')}
</a>
</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);
}

View File

@@ -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"

View 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>
);
}

View File

@@ -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 BrochureCTA from './BrochureCTA';
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">
@@ -186,12 +188,15 @@ export default function Footer() {
{navT('contact')}
</Link>
</li>
<li className="pt-2">
<BrochureCTA compact className="opacity-80 hover:opacity-100 transition-opacity" />
</li>
</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 +247,7 @@ 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="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

View File

@@ -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>
</>
);
}

View File

@@ -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-3xl md:text-4xl font-bold mt-12 mb-6 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-2xl md:text-3xl font-bold mt-10 mb-5 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-xl md:text-2xl font-bold mt-8 mb-4 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: {

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View 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;
}

View File

@@ -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>
);

View File

@@ -74,7 +74,7 @@ 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, {
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'short',
day: 'numeric',

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 '';
}

View File

@@ -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) {

633
lib/pdf-brochure.tsx Normal file
View File

@@ -0,0 +1,633 @@
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>;
}
// ─── 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: 'Produktkatalog',
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
property: 'Eigenschaft', value: 'Wert',
} : {
catalog: 'Product Catalog',
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
application: 'Application', specs: 'Technical Data', contact: 'Contact',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
property: 'Property', value: 'Value',
};
// ─── 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 }} />;
// ═══════════════════════════════════════════════════════════════════════════
// PAGE 1: COVER
// ═══════════════════════════════════════════════════════════════════════════
const CoverPage: React.FC<{
locale: 'en' | 'de';
introContent?: BrochureProps['introContent'];
logoWhite?: string | Buffer;
galleryImages?: Array<string | Buffer>;
}> = ({ 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: 56, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -1, lineHeight: 1 }}>
{l.catalog}
</Text>
<Text style={{ fontSize: 14, color: C.gray300, lineHeight: 1.8, marginTop: 20, 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 2N: INFO PAGES (each marketing section = own page)
// ═══════════════════════════════════════════════════════════════════════════
const InfoPage: React.FC<{
section: NonNullable<BrochureProps['marketingSections']>[0];
image?: string | Buffer;
logoBlack?: string | Buffer;
dark?: boolean;
}> = ({ section, image, logoBlack, dark }) => {
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;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
{/* Full-width image at top */}
{imgValid(image) && (
<View style={{ height: 200, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{dark && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.15 }} />}
</View>
)}
{/* Label + Title */}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
<Text style={{ fontSize: 28, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 8 }}>{section.title}</Text>
<AccentBar />
{/* Description */}
{section.description && (
<View style={{ marginBottom: 24 }}>
<RichText style={{ fontSize: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
{section.description}
</RichText>
</View>
)}
{/* Highlights — horizontal stat cards */}
{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: 14, paddingHorizontal: 14,
}}>
<Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
<Text style={{ fontSize: 8, 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: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
{section.pullQuote}"
</Text>
</View>
)}
{/* Items — 2-column grid with accent bars */}
{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}>
<View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
<Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
<RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
{item.description}
</RichText>
</View>
))}
</View>
)}
</Page>
);
};
// About page (first info page, special layout with values grid)
const AboutPage: React.FC<{
locale: 'en' | 'de';
companyInfo: BrochureProps['companyInfo'];
logoBlack?: string | Buffer;
image?: string | Buffer;
}> = ({ locale, companyInfo, logoBlack, image }) => {
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" />
{/* Full-width image at top */}
{imgValid(image) && (
<View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View>
)}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
<Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
<AccentBar />
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
{companyInfo.tagline}
</RichText>
{/* Values grid */}
<View style={{ marginTop: 32 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16 }}>{l.values}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
{companyInfo.values.map((v, i) => (
<View key={i} style={{ width: '46%', marginBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
</View>
<Text style={{ fontSize: 11, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
</View>
<Text style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6, paddingLeft: 38 }}>{v.description}</Text>
</View>
))}
</View>
</View>
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// TOC PAGE
// ═══════════════════════════════════════════════════════════════════════════
const TocPage: React.FC<{
products: BrochureProduct[];
locale: 'en' | 'de';
logoBlack?: string | Buffer;
productStartPage: number;
image?: string | Buffer;
}> = ({ products, locale, logoBlack, productStartPage, image }) => {
const l = labels(locale);
const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
let idx = 0;
for (const p of products) {
const cat = p.categories[0]?.name || 'Other';
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
idx++;
}
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right={l.overview} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
{/* Image strip */}
{imgValid(image) && (
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View>
)}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.catalog}</Text>
<Text style={{ fontSize: 28, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>{l.toc}</Text>
<AccentBar />
{Array.from(grouped.entries()).map(([cat, items]) => (
<View key={cat} style={{ marginBottom: 16 }}>
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 6 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2 }}>{cat}</Text>
</View>
{items.map((item, i) => (
<View key={i} style={{
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 5,
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
}}>
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep }}>{item.product.name}</Text>
<Text style={{ fontSize: 9, color: C.gray400 }}>{l.page} {item.pageNum}</Text>
</View>
))}
</View>
))}
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCT PAGES
// ═══════════════════════════════════════════════════════════════════════════
const ProductPage: React.FC<{
product: BrochureProduct;
locale: 'en' | 'de';
logoBlack?: string | Buffer;
}> = ({ product, locale, logoBlack }) => {
const l = labels(locale);
const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right={l.overview} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
{/* Category + Name */}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
{product.categories.map(c => c.name).join(' · ')}
</Text>
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.3, marginBottom: 8 }}>{product.name}</Text>
<AccentBar />
{/* Full-width product image */}
<View style={{
height: 160, marginHorizontal: -MARGIN, marginBottom: 24,
backgroundColor: C.offWhite,
justifyContent: 'center', alignItems: 'center',
padding: 16,
}}>
{product.featuredImage ? (
<Image src={product.featuredImage} style={{ maxWidth: '80%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
)}
</View>
{/* Description + QR in two columns */}
<View style={{ flexDirection: 'row', gap: 32, marginBottom: 24 }}>
<View style={{ flex: 2 }}>
{desc && (
<View>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.application}</Text>
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }}>{desc}</RichText>
</View>
)}
</View>
<View style={{ flex: 1 }}>
{(product.qrWebsite || product.qrDatasheet) && (
<View style={{ gap: 14 }}>
{product.qrWebsite && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrWebsite} style={{ width: 40, height: 40 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{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: 10 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrDatasheet} style={{ width: 40, height: 40 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrPdf}</Text>
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
</View>
</View>
)}
</View>
)}
</View>
</View>
{/* Technical Data */}
{product.attributes && product.attributes.length > 0 && (
<View>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
{/* Clean table header */}
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
<View style={{ width: '55%' }}>
<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,
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: '55%', paddingRight: 8 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 9, color: C.gray900 }}>{attr.options.join(', ')}</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,
}) => {
// Calculate actual page numbers
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections
const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC
// Assign images to sections: dark sections get indices 2,4; light get 3
const sectionThemes: Array<'light' | 'dark'> = [];
if (marketingSections) {
for (let i = 0; i < marketingSections.length; i++) {
// Alternate: light, dark, light, dark, light, dark
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
}
}
return (
<Document>
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
{/* About page with image index 1 */}
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
{/* Each marketing section gets its own page */}
{marketingSections?.map((section, i) => (
<InfoPage
key={`info-${i}`}
section={section}
image={galleryImages?.[i + 2]}
logoBlack={logoBlack}
dark={sectionThemes[i] === 'dark'}
/>
))}
{/* TOC */}
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
{/* Products — each on its own page */}
{products.map(p => (
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
))}
{/* Back cover */}
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
</Document>
);
};

View File

@@ -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,
};
});

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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
View 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
};
}

BIN
lychee Executable file

Binary file not shown.

View File

@@ -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"
}
}
}

View File

@@ -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
View File

@@ -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.

View File

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

View File

@@ -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",
@@ -161,4 +163,4 @@
"peerDependencies": {
"lucide-react": "^0.563.0"
}
}
}

8
pnpm-lock.yaml generated
View File

@@ -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==}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/logo-black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

40
public/logo-black.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 295 99" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0.000798697,0)">
<path d="M83.219,92.879C83.219,93.629 82.973,94.043 81.992,94.043C81.008,94.043 80.82,93.629 80.82,92.91L80.82,89.969C80.82,89.25 81.008,88.836 81.992,88.836C83.043,88.836 83.219,89.25 83.219,89.988L84.578,89.988C84.578,88.305 83.82,87.637 81.992,87.637C80.16,87.637 79.461,88.297 79.461,89.898L79.461,92.98C79.461,94.543 80.191,95.242 81.992,95.242C83.793,95.242 84.578,94.543 84.578,92.879L83.219,92.879Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M90.543,87.656L89.195,87.656L87.102,95.223L88.496,95.223L88.891,93.883L90.828,93.883L91.211,95.223L92.609,95.223L90.543,87.656ZM89.227,92.555L89.855,89.754L90.484,92.555L89.227,92.555Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M95.336,95.223L97.836,95.223C99.668,95.223 100.523,94.574 100.523,92.871C100.523,91.828 99.922,91.148 99.137,90.98C99.734,90.578 99.824,90.117 99.824,89.652C99.824,88.473 98.957,87.648 97.59,87.648L95.336,87.648L95.336,95.223ZM96.688,91.809L97.836,91.809C98.82,91.809 99.066,92.152 99.066,92.898C99.066,93.617 98.91,93.992 97.855,93.992L96.688,93.992L96.688,91.809ZM97.59,88.809C98.258,88.809 98.426,89.289 98.426,89.672C98.426,90.156 98.16,90.559 97.602,90.559L96.695,90.559L96.695,88.809L97.59,88.809Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M107.906,93.98L104.98,93.98L104.98,87.648L103.613,87.648L103.613,95.223L107.906,95.223L107.906,93.98Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M110.879,87.648L110.879,95.23L115.375,95.23L115.375,93.992L112.238,93.992L112.238,91.996L114.793,91.996L114.793,90.773L112.238,90.773L112.238,88.828L115.238,88.828L115.238,87.648L110.879,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M121.684,89.625L123.051,89.625C122.926,88.109 122.02,87.605 120.652,87.605C119.098,87.605 118.23,88.344 118.23,89.762C118.23,91.324 119.137,91.75 119.992,91.855C120.797,91.965 121.863,91.965 121.863,92.859C121.863,93.715 121.488,94.062 120.672,94.062C119.805,94.062 119.551,93.746 119.52,93.164L118.152,93.164C118.152,94.387 118.754,95.301 120.641,95.301C122.461,95.301 123.219,94.562 123.219,92.812C123.219,91.297 122.383,90.941 121.508,90.805C120.355,90.629 119.598,90.707 119.598,89.754C119.598,89.035 119.902,88.797 120.652,88.797C121.309,88.797 121.645,88.984 121.684,89.625Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M135.348,87.648L130.91,87.648L130.91,95.23L132.258,95.23L132.258,92.004L134.875,92.004L134.875,90.773L132.258,90.773L132.258,88.887L135.348,88.887L135.348,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M140.82,95.289C142.621,95.289 143.406,94.594 143.406,93.027L143.406,89.82C143.406,88.219 142.648,87.559 140.82,87.559C138.988,87.559 138.289,88.219 138.289,89.82L138.289,93.027C138.289,94.594 139.02,95.289 140.82,95.289ZM140.82,94.09C139.836,94.09 139.648,93.676 139.648,92.961L139.648,89.891C139.648,89.199 139.836,88.758 140.82,88.758C141.871,88.758 142.051,89.199 142.051,89.891L142.051,92.961C142.051,93.676 141.805,94.09 140.82,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M151.703,95.223L150.039,92.34C150.957,92.043 151.348,91.434 151.348,90.312L151.348,89.918C151.348,88.316 150.492,87.648 148.664,87.648L146.754,87.648L146.754,95.223L148.113,95.223L148.113,92.555L148.613,92.555L150.121,95.223L151.703,95.223ZM148.102,91.305L148.102,88.895L148.684,88.895C149.734,88.895 149.922,89.27 149.922,89.988L149.922,90.242C149.922,90.961 149.648,91.305 148.664,91.305L148.102,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M161.707,87.656L160.359,87.656L158.262,95.223L159.66,95.223L160.055,93.883L161.992,93.883L162.375,95.223L163.773,95.223L161.707,87.656ZM160.387,92.555L161.016,89.754L161.648,92.555L160.387,92.555Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M173.254,92.145L174.543,92.145L174.543,92.879C174.543,93.629 174.195,94.043 173.215,94.043C172.23,94.043 172.043,93.629 172.043,92.91L172.043,89.938C172.043,89.25 172.23,88.809 173.215,88.809C174.266,88.809 174.441,89.16 174.441,89.871L175.801,89.871C175.801,88.246 175.043,87.605 173.215,87.605C171.383,87.605 170.684,88.324 170.684,89.871L170.684,92.91C170.684,94.543 171.414,95.262 173.215,95.262C175.012,95.262 175.801,94.543 175.801,92.879L175.801,90.914L173.254,90.914L173.254,92.145Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M184.02,95.223L182.355,92.34C183.27,92.043 183.664,91.434 183.664,90.312L183.664,89.918C183.664,88.316 182.809,87.648 180.98,87.648L179.07,87.648L179.07,95.223L180.426,95.223L180.426,92.555L180.93,92.555L182.434,95.223L184.02,95.223ZM180.418,91.305L180.418,88.895L181,88.895C182.051,88.895 182.238,89.27 182.238,89.988L182.238,90.242C182.238,90.961 181.961,91.305 180.98,91.305L180.418,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M186.965,87.648L186.965,95.23L191.461,95.23L191.461,93.992L188.32,93.992L188.32,91.996L190.879,91.996L190.879,90.773L188.32,90.773L188.32,88.828L191.32,88.828L191.32,87.648L186.965,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M194.562,87.648L194.562,95.23L199.059,95.23L199.059,93.992L195.918,93.992L195.918,91.996L198.477,91.996L198.477,90.773L195.918,90.773L195.918,88.828L198.922,88.828L198.922,87.648L194.562,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M206.922,87.656L205.574,87.656L205.574,89.445L205.723,92.645L203.496,87.656L202.148,87.656L202.148,95.223L203.496,95.223L203.496,93.293L203.379,90.422L205.602,95.223L206.922,95.223L206.922,87.656Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M210.34,87.648L210.34,95.23L214.836,95.23L214.836,93.992L211.695,93.992L211.695,91.996L214.254,91.996L214.254,90.773L211.695,90.773L211.695,88.828L214.695,88.828L214.695,87.648L210.34,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M222.887,95.223L221.223,92.34C222.137,92.043 222.531,91.434 222.531,90.312L222.531,89.918C222.531,88.316 221.676,87.648 219.848,87.648L217.938,87.648L217.938,95.223L219.293,95.223L219.293,92.555L219.797,92.555L221.301,95.223L222.887,95.223ZM219.285,91.305L219.285,88.895L219.867,88.895C220.918,88.895 221.105,89.27 221.105,89.988L221.105,90.242C221.105,90.961 220.828,91.305 219.844,91.305L219.285,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M233.93,87.676L229.445,87.676L229.445,88.887L231.02,88.887L231.02,95.223L232.367,95.223L232.367,88.887L233.93,88.887L233.93,87.676Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M238.922,95.289C240.723,95.289 241.508,94.594 241.508,93.027L241.508,89.82C241.508,88.219 240.75,87.559 238.922,87.559C237.094,87.559 236.395,88.219 236.395,89.82L236.395,93.027C236.395,94.594 237.121,95.289 238.922,95.289ZM238.922,94.09C237.938,94.09 237.75,93.676 237.75,92.961L237.75,89.891C237.75,89.199 237.938,88.758 238.922,88.758C239.973,88.758 240.152,89.199 240.152,89.891L240.152,92.961C240.152,93.676 239.906,94.09 238.922,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M247.867,93.43L249.375,90.383L249.215,92.547L249.215,95.223L250.574,95.223L250.574,87.648L249.266,87.648L247.711,91L246.164,87.648L244.859,87.648L244.859,95.223L246.215,95.223L246.215,92.547L246.059,90.383L247.562,93.43L247.867,93.43Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M256.41,95.289C258.211,95.289 259,94.594 259,93.027L259,89.82C259,88.219 258.242,87.559 256.41,87.559C254.582,87.559 253.883,88.219 253.883,89.82L253.883,93.027C253.883,94.594 254.609,95.289 256.41,95.289ZM256.41,94.09C255.426,94.09 255.238,93.676 255.238,92.961L255.238,89.891C255.238,89.199 255.426,88.758 256.41,88.758C257.465,88.758 257.64,89.199 257.64,89.891L257.64,92.961C257.64,93.676 257.394,94.09 256.41,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M267.297,95.223L265.633,92.34C266.547,92.043 266.941,91.434 266.941,90.312L266.941,89.918C266.941,88.316 266.086,87.648 264.254,87.648L262.348,87.648L262.348,95.223L263.703,95.223L263.703,92.555L264.207,92.555L265.711,95.223L267.297,95.223ZM263.695,91.305L263.695,88.895L264.273,88.895C265.328,88.895 265.516,89.27 265.516,89.988L265.516,90.242C265.516,90.961 265.238,91.305 264.254,91.305L263.695,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M275.188,95.223L273.527,92.34C274.441,92.043 274.836,91.434 274.836,90.312L274.836,89.918C274.836,88.316 273.977,87.648 272.148,87.648L270.238,87.648L270.238,95.223L271.598,95.223L271.598,92.555L272.098,92.555L273.605,95.223L275.188,95.223ZM271.586,91.305L271.586,88.895L272.168,88.895C273.223,88.895 273.406,89.27 273.406,89.988L273.406,90.242C273.406,90.961 273.133,91.305 272.148,91.305L271.586,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M280.555,95.289C282.355,95.289 283.141,94.594 283.141,93.027L283.141,89.82C283.141,88.219 282.383,87.559 280.555,87.559C278.723,87.559 278.023,88.219 278.023,89.82L278.023,93.027C278.023,94.594 278.754,95.289 280.555,95.289ZM280.555,94.09C279.57,94.09 279.383,93.676 279.383,92.961L279.383,89.891C279.383,89.199 279.57,88.758 280.555,88.758C281.605,88.758 281.785,89.199 281.785,89.891L281.785,92.961C281.785,93.676 281.539,94.09 280.555,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M290.141,91.141L291.25,95.23L292.688,95.23L294.586,87.648L293.188,87.648L292.441,90.262L292,93.352L290.66,87.895L289.617,87.895L288.379,93.352L287.836,90.262L287.09,87.648L285.691,87.648L287.699,95.23L289.125,95.23L290.141,91.141Z" style="fill:#000000;fill-rule:nonzero;"></path>
<path d="M90.383,76.133C90.336,76.137 90.293,76.141 90.25,76.141L80.816,76.141C80.773,76.141 80.73,76.137 80.684,76.133C80.641,76.129 80.598,76.121 80.555,76.113C80.508,76.105 80.465,76.094 80.426,76.082C80.383,76.066 80.34,76.055 80.297,76.035C80.258,76.02 80.219,76 80.18,75.98C80.141,75.957 80.102,75.934 80.066,75.91C80.027,75.887 79.992,75.859 79.957,75.832C79.922,75.805 79.891,75.773 79.859,75.742C79.828,75.711 79.797,75.68 79.77,75.645C79.742,75.609 79.715,75.574 79.691,75.535C79.668,75.5 79.645,75.461 79.621,75.422C79.602,75.383 79.582,75.344 79.566,75.301C79.547,75.262 79.535,75.219 79.52,75.176C79.508,75.137 79.496,75.094 79.488,75.047C79.48,75.004 79.473,74.961 79.469,74.918C79.465,74.871 79.461,74.828 79.461,74.785L79.461,17.875C79.461,17.828 79.465,17.785 79.469,17.742C79.473,17.695 79.48,17.652 79.488,17.609C79.496,17.566 79.508,17.523 79.52,17.48C79.535,17.438 79.547,17.395 79.566,17.355C79.582,17.312 79.602,17.273 79.621,17.234C79.645,17.195 79.668,17.156 79.691,17.121C79.715,17.082 79.742,17.047 79.77,17.012C79.797,16.98 79.828,16.945 79.859,16.914C79.891,16.883 79.922,16.855 79.957,16.824C79.992,16.797 80.027,16.77 80.066,16.746C80.102,16.723 80.141,16.699 80.18,16.68C80.219,16.656 80.258,16.637 80.297,16.621C80.34,16.605 80.383,16.59 80.426,16.578C80.465,16.562 80.508,16.555 80.555,16.543C80.598,16.535 80.641,16.527 80.684,16.523C80.73,16.52 80.773,16.52 80.816,16.52L90.25,16.52C90.293,16.52 90.336,16.52 90.383,16.523C90.426,16.527 90.469,16.535 90.512,16.543C90.555,16.555 90.598,16.562 90.641,16.578C90.684,16.59 90.727,16.605 90.766,16.621C90.809,16.637 90.848,16.656 90.887,16.68C90.926,16.699 90.965,16.723 91,16.746C91.039,16.77 91.074,16.797 91.109,16.824C91.141,16.855 91.176,16.883 91.207,16.914C91.238,16.945 91.27,16.98 91.297,17.012C91.324,17.047 91.352,17.082 91.375,17.121C91.398,17.156 91.422,17.195 91.441,17.234C91.465,17.273 91.484,17.312 91.5,17.355C91.516,17.395 91.531,17.438 91.547,17.48C91.559,17.523 91.57,17.566 91.578,17.609C91.586,17.652 91.594,17.695 91.598,17.742C91.602,17.785 91.602,17.828 91.602,17.875L91.602,44.699L129.363,16.785C129.477,16.695 129.604,16.633 129.742,16.586C129.882,16.539 130.022,16.52 130.168,16.52L143.746,16.52C143.852,16.52 143.953,16.531 144.059,16.555C144.16,16.578 144.258,16.613 144.355,16.664C144.449,16.711 144.535,16.77 144.617,16.836C144.699,16.906 144.77,16.98 144.832,17.066C144.859,17.102 144.883,17.137 144.906,17.176C144.93,17.215 144.949,17.254 144.969,17.293C144.988,17.332 145.004,17.375 145.02,17.418C145.035,17.457 145.047,17.5 145.059,17.543C145.07,17.586 145.078,17.629 145.086,17.676C145.09,17.719 145.098,17.762 145.098,17.805C145.102,17.852 145.102,17.895 145.098,17.941C145.098,17.984 145.09,18.027 145.086,18.07C145.078,18.117 145.07,18.16 145.059,18.203C145.047,18.246 145.035,18.289 145.02,18.328C145.004,18.371 144.988,18.414 144.969,18.453C144.949,18.492 144.93,18.531 144.906,18.57C144.883,18.609 144.859,18.645 144.832,18.68C144.805,18.715 144.777,18.75 144.75,18.781C144.719,18.816 144.688,18.848 144.656,18.879C144.621,18.906 144.586,18.934 144.551,18.961L91.602,58.23L91.602,74.785C91.602,74.828 91.602,74.871 91.598,74.918C91.594,74.961 91.586,75.004 91.578,75.047C91.57,75.094 91.559,75.137 91.547,75.176C91.531,75.219 91.516,75.262 91.5,75.301C91.484,75.344 91.465,75.383 91.441,75.422C91.422,75.461 91.398,75.5 91.375,75.535C91.352,75.574 91.324,75.609 91.297,75.645C91.27,75.68 91.238,75.711 91.207,75.742C91.176,75.773 91.141,75.805 91.109,75.832C91.074,75.859 91.039,75.887 91,75.91C90.965,75.934 90.926,75.957 90.887,75.98C90.848,76 90.809,76.02 90.766,76.035C90.727,76.055 90.684,76.066 90.641,76.082C90.598,76.094 90.555,76.105 90.512,76.113C90.469,76.121 90.426,76.129 90.383,76.133ZM88.93,57.234C88.906,57.34 88.895,57.441 88.895,57.547L88.895,73.43L82.172,73.43L82.172,19.227L88.895,19.227L88.895,47.387C88.895,47.531 88.914,47.672 88.961,47.809C89.008,47.949 89.074,48.074 89.16,48.191C89.211,48.262 89.27,48.328 89.336,48.387C89.402,48.449 89.473,48.5 89.551,48.547C89.625,48.594 89.707,48.629 89.789,48.66C89.875,48.691 89.961,48.711 90.047,48.727C90.137,48.738 90.223,48.742 90.312,48.738C90.402,48.734 90.488,48.723 90.574,48.699C90.66,48.68 90.746,48.648 90.824,48.613C90.906,48.574 90.98,48.527 91.055,48.477L130.613,19.227L139.645,19.227L89.441,56.461C89.355,56.523 89.281,56.594 89.211,56.676C89.145,56.758 89.086,56.844 89.039,56.938C88.992,57.035 88.953,57.133 88.93,57.234Z" style="fill:#000000;"></path>
<path d="M148.246,74.535C148.262,74.617 148.27,74.699 148.27,74.785C148.27,74.828 148.27,74.871 148.262,74.918C148.258,74.961 148.254,75.004 148.246,75.047C148.234,75.094 148.227,75.137 148.211,75.176C148.199,75.219 148.184,75.262 148.168,75.301C148.148,75.344 148.133,75.383 148.109,75.422C148.09,75.461 148.066,75.5 148.043,75.535C148.016,75.574 147.992,75.609 147.961,75.645C147.934,75.68 147.906,75.711 147.875,75.742C147.844,75.773 147.809,75.805 147.773,75.832C147.742,75.859 147.707,75.887 147.668,75.91C147.633,75.934 147.594,75.957 147.555,75.98C147.516,76 147.477,76.02 147.434,76.035C147.395,76.055 147.352,76.066 147.309,76.082C147.266,76.094 147.223,76.105 147.18,76.113C147.137,76.121 147.094,76.129 147.047,76.133C147.004,76.137 146.961,76.141 146.914,76.141L134.719,76.141C134.625,76.141 134.531,76.129 134.441,76.109C134.348,76.09 134.258,76.062 134.172,76.023C134.086,75.984 134.004,75.938 133.926,75.883C133.852,75.828 133.781,75.766 133.719,75.695L110.465,50.086C110.438,50.055 110.41,50.02 110.383,49.988C110.355,49.953 110.332,49.914 110.309,49.879C110.285,49.84 110.266,49.801 110.246,49.762C110.227,49.719 110.211,49.68 110.195,49.637C110.18,49.598 110.168,49.555 110.156,49.512C110.145,49.469 110.137,49.426 110.129,49.379C110.121,49.336 110.117,49.293 110.113,49.246L110.113,49.113C110.117,49.07 110.121,49.027 110.125,48.984C110.133,48.938 110.141,48.895 110.152,48.852C110.164,48.809 110.176,48.766 110.191,48.723C110.203,48.684 110.223,48.641 110.238,48.602C110.258,48.562 110.281,48.523 110.301,48.484C110.324,48.445 110.348,48.41 110.375,48.371C110.402,48.336 110.43,48.301 110.461,48.27C110.488,48.238 110.52,48.203 110.551,48.176C110.586,48.145 110.621,48.117 110.656,48.09L117.809,42.723C117.844,42.699 117.879,42.676 117.914,42.652C117.949,42.633 117.984,42.613 118.023,42.594C118.059,42.574 118.098,42.559 118.137,42.543C118.176,42.527 118.215,42.516 118.254,42.504C118.293,42.492 118.336,42.484 118.375,42.477C118.418,42.469 118.457,42.461 118.5,42.457C118.543,42.457 118.582,42.453 118.625,42.453C118.668,42.453 118.707,42.457 118.75,42.461C118.789,42.465 118.832,42.469 118.871,42.477C118.914,42.484 118.953,42.492 118.992,42.504C119.035,42.516 119.074,42.531 119.113,42.547C119.152,42.559 119.188,42.578 119.227,42.594C119.266,42.613 119.301,42.633 119.336,42.656C119.371,42.68 119.406,42.703 119.438,42.727C119.473,42.75 119.504,42.777 119.535,42.805C119.566,42.836 119.594,42.863 119.621,42.895L147.918,73.871C147.973,73.934 148.023,74 148.066,74.07C148.109,74.141 148.148,74.215 148.18,74.293C148.211,74.371 148.23,74.453 148.246,74.535ZM135.32,73.43L113.473,49.363L118.453,45.629L143.844,73.43L135.32,73.43Z" style="fill:#000000;"></path>
<path d="M171.984,16.52C171.938,16.52 171.895,16.52 171.852,16.523C171.805,16.527 171.762,16.535 171.719,16.543C171.676,16.555 171.633,16.562 171.59,16.578C171.547,16.59 171.504,16.605 171.465,16.621C171.426,16.637 171.383,16.656 171.344,16.68C171.305,16.699 171.27,16.723 171.23,16.746C171.195,16.77 171.156,16.797 171.125,16.824C171.09,16.855 171.055,16.883 171.023,16.914C170.992,16.945 170.965,16.98 170.938,17.012C170.906,17.047 170.883,17.082 170.855,17.121C170.832,17.156 170.809,17.195 170.789,17.234C170.766,17.273 170.75,17.312 170.73,17.355C170.715,17.395 170.699,17.438 170.688,17.48C170.676,17.523 170.664,17.566 170.652,17.609C170.645,17.652 170.641,17.695 170.637,17.742C170.629,17.785 170.629,17.828 170.629,17.875L170.629,74.785C170.629,74.828 170.629,74.871 170.637,74.918C170.641,74.961 170.645,75.004 170.652,75.047C170.664,75.094 170.676,75.137 170.688,75.176C170.699,75.219 170.715,75.262 170.73,75.301C170.75,75.344 170.766,75.383 170.789,75.422C170.809,75.461 170.832,75.5 170.855,75.535C170.883,75.574 170.906,75.609 170.938,75.645C170.965,75.68 170.992,75.711 171.023,75.742C171.055,75.773 171.09,75.805 171.125,75.832C171.156,75.859 171.195,75.887 171.23,75.91C171.27,75.934 171.305,75.957 171.344,75.98C171.383,76 171.426,76.02 171.465,76.035C171.504,76.055 171.547,76.066 171.59,76.082C171.633,76.094 171.676,76.105 171.719,76.113C171.762,76.121 171.805,76.129 171.852,76.133C171.895,76.137 171.938,76.141 171.984,76.141L212.391,76.141C212.434,76.141 212.48,76.137 212.523,76.133C212.566,76.129 212.609,76.121 212.656,76.113C212.699,76.105 212.742,76.094 212.785,76.082C212.828,76.066 212.867,76.055 212.91,76.035C212.949,76.02 212.988,76 213.027,75.98C213.066,75.957 213.105,75.934 213.145,75.91C213.18,75.887 213.215,75.859 213.25,75.832C213.285,75.805 213.316,75.773 213.348,75.742C213.379,75.711 213.41,75.68 213.438,75.645C213.465,75.609 213.492,75.574 213.516,75.535C213.543,75.5 213.566,75.461 213.586,75.422C213.605,75.383 213.625,75.344 213.641,75.301C213.66,75.262 213.672,75.219 213.688,75.176C213.699,75.137 213.711,75.094 213.719,75.047C213.727,75.004 213.734,74.961 213.738,74.918C213.742,74.871 213.746,74.828 213.746,74.785L213.746,66.328C213.746,66.285 213.742,66.238 213.738,66.195C213.734,66.152 213.727,66.109 213.719,66.062C213.711,66.02 213.699,65.977 213.688,65.934C213.672,65.895 213.66,65.852 213.641,65.809C213.625,65.77 213.605,65.73 213.586,65.691C213.566,65.652 213.543,65.613 213.516,65.574C213.492,65.539 213.465,65.504 213.438,65.469C213.41,65.434 213.379,65.402 213.348,65.372C213.316,65.34 213.285,65.309 213.25,65.281C213.215,65.254 213.18,65.227 213.145,65.204C213.105,65.177 213.066,65.156 213.027,65.134C212.988,65.113 212.949,65.094 212.91,65.079C212.867,65.059 212.828,65.047 212.785,65.031C212.742,65.02 212.699,65.009 212.656,65C212.609,64.992 212.566,64.984 212.523,64.981C212.48,64.977 212.434,64.973 212.391,64.973L182.77,64.973L182.77,17.875C182.77,17.828 182.766,17.785 182.762,17.742C182.758,17.695 182.754,17.652 182.742,17.609C182.734,17.566 182.723,17.523 182.711,17.48C182.699,17.438 182.684,17.395 182.668,17.355C182.648,17.312 182.629,17.273 182.609,17.234C182.59,17.195 182.566,17.156 182.543,17.121C182.516,17.082 182.488,17.047 182.461,17.012C182.434,16.98 182.402,16.945 182.371,16.914C182.34,16.883 182.309,16.855 182.273,16.824C182.238,16.797 182.203,16.77 182.168,16.746C182.129,16.723 182.094,16.699 182.055,16.68C182.016,16.656 181.973,16.637 181.934,16.621C181.891,16.605 181.852,16.59 181.809,16.578C181.766,16.562 181.723,16.555 181.68,16.543C181.637,16.535 181.59,16.527 181.547,16.523C181.504,16.52 181.457,16.52 181.414,16.52L171.984,16.52ZM173.34,19.227L173.34,73.43L211.035,73.43L211.035,67.684L181.414,67.684C181.371,67.684 181.324,67.68 181.281,67.676C181.238,67.672 181.195,67.668 181.148,67.656C181.105,67.648 181.062,67.637 181.02,67.625C180.977,67.613 180.938,67.598 180.895,67.582C180.855,67.562 180.816,67.543 180.777,67.523C180.738,67.504 180.699,67.48 180.66,67.457C180.625,67.43 180.59,67.406 180.555,67.375C180.52,67.348 180.488,67.316 180.457,67.285C180.426,67.254 180.395,67.223 180.367,67.188C180.34,67.152 180.312,67.117 180.289,67.082C180.262,67.043 180.238,67.008 180.219,66.969C180.199,66.93 180.18,66.887 180.164,66.848C180.145,66.805 180.129,66.766 180.117,66.723C180.105,66.68 180.094,66.637 180.086,66.594C180.078,66.551 180.07,66.504 180.066,66.461C180.062,66.418 180.059,66.375 180.059,66.328L180.059,19.227L173.34,19.227Z" style="fill:#000000;"></path>
<path d="M294.578,66.195C294.582,66.238 294.586,66.285 294.586,66.328L294.586,74.785C294.586,74.828 294.582,74.871 294.578,74.918C294.574,74.961 294.57,75.004 294.559,75.047C294.551,75.094 294.539,75.137 294.527,75.176C294.516,75.219 294.5,75.262 294.484,75.301C294.465,75.344 294.445,75.383 294.426,75.422C294.406,75.461 294.383,75.5 294.359,75.535C294.332,75.574 294.305,75.609 294.277,75.645C294.25,75.68 294.219,75.711 294.188,75.742C294.156,75.773 294.125,75.805 294.09,75.832C294.055,75.859 294.02,75.887 293.984,75.91C293.945,75.934 293.91,75.957 293.871,75.98C293.832,76 293.789,76.02 293.75,76.035C293.707,76.055 293.668,76.066 293.625,76.082C293.582,76.094 293.539,76.105 293.496,76.113C293.453,76.121 293.406,76.129 293.363,76.133C293.32,76.137 293.273,76.141 293.23,76.141L237.457,76.141C237.414,76.141 237.371,76.137 237.324,76.133C237.281,76.129 237.238,76.121 237.195,76.113C237.148,76.105 237.105,76.094 237.062,76.082C237.023,76.066 236.98,76.055 236.941,76.035C236.898,76.02 236.859,76 236.82,75.98C236.781,75.957 236.742,75.934 236.707,75.91C236.668,75.887 236.633,75.859 236.598,75.832C236.562,75.805 236.531,75.773 236.5,75.742C236.469,75.711 236.438,75.68 236.41,75.645C236.383,75.609 236.355,75.574 236.332,75.535C236.305,75.5 236.285,75.461 236.262,75.422C236.242,75.383 236.223,75.344 236.207,75.301C236.188,75.262 236.176,75.219 236.16,75.176C236.148,75.137 236.137,75.094 236.129,75.047C236.121,75.004 236.113,74.961 236.109,74.918C236.105,74.871 236.102,74.828 236.102,74.785L236.102,66.328C236.102,66.23 236.113,66.137 236.133,66.043C236.156,65.945 236.184,65.855 236.227,65.766C236.266,65.68 236.312,65.594 236.371,65.52C236.43,65.441 236.496,65.372 236.57,65.305L292.344,16.852C292.402,16.797 292.469,16.75 292.539,16.707C292.609,16.668 292.68,16.633 292.758,16.605C292.832,16.574 292.91,16.555 292.988,16.539C293.07,16.523 293.148,16.52 293.23,16.52C293.273,16.52 293.32,16.52 293.363,16.523C293.406,16.527 293.453,16.535 293.496,16.543C293.539,16.555 293.582,16.562 293.625,16.578C293.668,16.59 293.707,16.605 293.75,16.621C293.789,16.637 293.832,16.656 293.871,16.68C293.91,16.699 293.945,16.723 293.984,16.746C294.02,16.77 294.055,16.797 294.09,16.824C294.125,16.855 294.156,16.883 294.188,16.914C294.219,16.945 294.25,16.98 294.277,17.012C294.305,17.047 294.332,17.082 294.359,17.121C294.383,17.156 294.406,17.195 294.426,17.234C294.445,17.273 294.465,17.312 294.484,17.355C294.5,17.395 294.516,17.438 294.527,17.48C294.539,17.523 294.551,17.566 294.559,17.609C294.57,17.652 294.574,17.695 294.578,17.742C294.582,17.785 294.586,17.828 294.586,17.875L294.586,28.766C294.586,28.863 294.574,28.961 294.555,29.055C294.535,29.148 294.504,29.238 294.465,29.328C294.426,29.418 294.375,29.5 294.316,29.578C294.262,29.652 294.195,29.727 294.121,29.789L253.906,64.899L293.234,64.973C293.277,64.973 293.324,64.977 293.367,64.981C293.41,64.984 293.453,64.992 293.496,65C293.543,65.009 293.582,65.02 293.625,65.031C293.668,65.047 293.711,65.062 293.75,65.079C293.793,65.094 293.832,65.113 293.871,65.134C293.91,65.156 293.949,65.18 293.984,65.204C294.023,65.227 294.059,65.254 294.09,65.281C294.125,65.309 294.16,65.34 294.191,65.372C294.223,65.402 294.25,65.439 294.277,65.469C294.309,65.504 294.332,65.539 294.359,65.578C294.383,65.613 294.406,65.652 294.426,65.691C294.445,65.73 294.465,65.77 294.484,65.812C294.5,65.852 294.516,65.895 294.527,65.938C294.539,65.977 294.551,66.02 294.559,66.066C294.57,66.109 294.574,66.152 294.578,66.195ZM250.301,67.602L291.875,67.68L291.875,73.43L238.812,73.43L238.812,66.945L291.875,20.844L291.875,28.152L249.414,65.227C249.34,65.289 249.273,65.359 249.219,65.439C249.16,65.516 249.109,65.598 249.07,65.688C249.031,65.773 249,65.863 248.98,65.957C248.957,66.055 248.949,66.148 248.949,66.246C248.949,66.289 248.949,66.332 248.953,66.379C248.961,66.422 248.965,66.465 248.973,66.508C248.984,66.555 248.992,66.598 249.008,66.637C249.02,66.68 249.035,66.723 249.051,66.762C249.066,66.805 249.086,66.844 249.105,66.883C249.129,66.922 249.152,66.961 249.176,67C249.199,67.035 249.227,67.07 249.254,67.105C249.281,67.141 249.312,67.172 249.344,67.203C249.375,67.234 249.406,67.266 249.441,67.293C249.477,67.32 249.512,67.348 249.551,67.371C249.586,67.398 249.625,67.422 249.664,67.441C249.703,67.461 249.742,67.48 249.781,67.5C249.824,67.516 249.867,67.531 249.906,67.543C249.949,67.555 249.992,67.566 250.035,67.574C250.082,67.586 250.125,67.59 250.168,67.594C250.211,67.602 250.258,67.602 250.301,67.602Z" style="fill:#000000;"></path>
<path d="M238.059,16.523C238.102,16.52 238.145,16.52 238.191,16.52L281.281,16.52C281.383,16.52 281.484,16.531 281.582,16.551C281.684,16.574 281.781,16.609 281.871,16.656C281.965,16.699 282.051,16.754 282.133,16.82C282.211,16.883 282.281,16.957 282.348,17.039C282.375,17.074 282.398,17.109 282.422,17.145C282.445,17.184 282.469,17.223 282.488,17.262C282.508,17.301 282.527,17.34 282.543,17.383C282.559,17.426 282.574,17.465 282.586,17.508C282.598,17.551 282.605,17.594 282.613,17.641C282.621,17.684 282.629,17.727 282.629,17.77C282.633,17.816 282.637,17.859 282.633,17.902C282.633,17.949 282.629,17.992 282.625,18.035C282.621,18.082 282.613,18.125 282.602,18.168C282.594,18.211 282.582,18.254 282.566,18.297C282.555,18.336 282.539,18.379 282.52,18.418C282.5,18.461 282.48,18.5 282.461,18.539C282.438,18.578 282.414,18.613 282.387,18.652C282.363,18.688 282.336,18.723 282.309,18.758C282.277,18.789 282.246,18.82 282.215,18.852C282.184,18.883 282.148,18.914 282.117,18.941L271.223,27.477C271.102,27.57 270.969,27.641 270.828,27.691C270.684,27.738 270.535,27.766 270.387,27.766L238.191,27.766C238.145,27.766 238.102,27.762 238.059,27.758C238.012,27.754 237.969,27.746 237.926,27.738C237.883,27.73 237.84,27.719 237.797,27.707C237.754,27.695 237.711,27.68 237.672,27.66C237.629,27.645 237.59,27.625 237.551,27.605C237.512,27.582 237.473,27.562 237.438,27.535C237.398,27.512 237.363,27.484 237.328,27.457C237.297,27.43 237.262,27.398 237.23,27.367C237.199,27.336 237.172,27.305 237.141,27.27C237.113,27.234 237.09,27.199 237.062,27.164C237.039,27.125 237.016,27.086 236.996,27.047C236.973,27.008 236.953,26.969 236.938,26.93C236.922,26.887 236.906,26.844 236.895,26.805C236.879,26.762 236.871,26.719 236.859,26.676C236.852,26.629 236.844,26.586 236.84,26.543C236.836,26.5 236.836,26.453 236.836,26.41L236.836,17.875C236.836,17.828 236.836,17.785 236.84,17.742C236.844,17.695 236.852,17.652 236.859,17.609C236.871,17.566 236.879,17.523 236.895,17.48C236.906,17.438 236.922,17.395 236.938,17.355C236.953,17.312 236.973,17.273 236.996,17.234C237.016,17.195 237.039,17.156 237.062,17.121C237.09,17.082 237.113,17.047 237.141,17.012C237.172,16.98 237.199,16.945 237.23,16.914C237.262,16.883 237.297,16.855 237.328,16.824C237.363,16.797 237.398,16.77 237.438,16.746C237.473,16.723 237.512,16.699 237.551,16.68C237.59,16.656 237.629,16.637 237.672,16.621C237.711,16.605 237.754,16.59 237.797,16.578C237.84,16.562 237.883,16.555 237.926,16.543C237.969,16.535 238.012,16.527 238.059,16.523ZM277.352,19.227L269.918,25.055L239.543,25.055L239.543,19.227L277.352,19.227Z" style="fill:#000000;"></path>
<path d="M24.406,28.266L16.988,0.547C16.988,0.328 16.77,0.109 16.441,0.109L15.023,0C14.586,0 14.258,0.328 14.367,0.762L19.059,27.5C19.059,27.719 19.277,27.828 19.496,27.938L21.57,28.59C21.789,28.59 21.898,28.699 22.008,28.918C22.66,28.484 23.426,28.266 24.188,28.266L24.406,28.266Z" style="fill:#000000;"></path>
<path d="M26.688,32.547C26.695,32.465 26.699,32.383 26.699,32.301C26.699,32.219 26.695,32.137 26.688,32.055C26.68,31.973 26.668,31.895 26.652,31.812C26.633,31.73 26.613,31.652 26.59,31.574C26.566,31.496 26.539,31.418 26.508,31.34C26.477,31.266 26.441,31.191 26.402,31.117C26.363,31.047 26.32,30.977 26.277,30.906C26.23,30.84 26.18,30.773 26.129,30.711C26.078,30.648 26.023,30.586 25.965,30.527C25.906,30.469 25.844,30.414 25.781,30.363C25.719,30.309 25.652,30.262 25.582,30.215C25.516,30.168 25.445,30.125 25.371,30.09C25.301,30.051 25.227,30.016 25.148,29.984C25.074,29.953 24.996,29.926 24.918,29.898C24.84,29.875 24.758,29.855 24.68,29.84C24.598,29.824 24.516,29.812 24.434,29.805C24.352,29.797 24.27,29.793 24.188,29.793C24.105,29.793 24.023,29.797 23.945,29.805C23.859,29.812 23.781,29.824 23.699,29.84C23.617,29.855 23.539,29.875 23.461,29.898C23.383,29.926 23.305,29.953 23.23,29.984C23.152,30.016 23.078,30.051 23.008,30.09C22.934,30.125 22.863,30.168 22.793,30.215C22.727,30.262 22.66,30.309 22.598,30.363C22.535,30.414 22.473,30.469 22.414,30.527C22.355,30.586 22.301,30.648 22.25,30.711C22.195,30.773 22.148,30.84 22.102,30.906C22.055,30.977 22.016,31.047 21.977,31.117C21.938,31.191 21.902,31.266 21.871,31.34C21.84,31.418 21.812,31.496 21.789,31.574C21.762,31.652 21.742,31.73 21.727,31.812C21.711,31.895 21.699,31.973 21.691,32.055C21.684,32.137 21.68,32.219 21.68,32.301C21.68,32.383 21.684,32.465 21.691,32.547C21.699,32.629 21.711,32.711 21.727,32.793C21.742,32.871 21.762,32.953 21.789,33.031C21.812,33.109 21.84,33.188 21.871,33.262C21.902,33.34 21.938,33.414 21.977,33.484C22.016,33.559 22.055,33.629 22.102,33.695C22.148,33.766 22.195,33.832 22.25,33.895C22.301,33.957 22.355,34.02 22.414,34.078C22.473,34.137 22.535,34.191 22.598,34.242C22.66,34.293 22.727,34.344 22.793,34.391C22.863,34.434 22.934,34.477 23.008,34.516C23.078,34.555 23.152,34.59 23.23,34.621C23.305,34.652 23.383,34.68 23.461,34.703C23.539,34.727 23.617,34.746 23.699,34.766C23.781,34.781 23.859,34.793 23.945,34.801C24.023,34.809 24.105,34.812 24.188,34.812C24.27,34.812 24.352,34.809 24.434,34.801C24.516,34.793 24.598,34.781 24.68,34.766C24.758,34.746 24.84,34.727 24.918,34.703C24.996,34.68 25.074,34.652 25.148,34.621C25.227,34.59 25.301,34.555 25.371,34.516C25.445,34.477 25.516,34.434 25.582,34.391C25.652,34.344 25.719,34.293 25.781,34.242C25.844,34.191 25.906,34.137 25.965,34.078C26.023,34.02 26.078,33.957 26.129,33.895C26.18,33.832 26.23,33.766 26.277,33.695C26.32,33.629 26.363,33.559 26.402,33.484C26.441,33.414 26.477,33.34 26.508,33.262C26.539,33.188 26.566,33.109 26.59,33.031C26.613,32.953 26.633,32.871 26.652,32.793C26.668,32.711 26.68,32.629 26.688,32.547Z" style="fill:#000000;"></path>
<path d="M55.945,41.688L56.711,40.488C56.926,40.16 56.816,39.723 56.383,39.504L30.957,30.23L30.738,30.23C30.52,30.23 30.41,30.336 30.301,30.445L28.664,31.977C28.555,32.082 28.336,32.191 28.227,32.191L28.117,32.191L28.117,32.41C28.117,33.176 27.898,33.938 27.465,34.594L55.289,42.016L55.398,42.016C55.617,42.016 55.836,41.906 55.945,41.688Z" style="fill:#000000;"></path>
<path d="M1.707,56.527L21.68,39.941L22.551,39.176C22.66,39.066 22.77,38.742 22.66,38.523L22.117,36.34C22.117,36.121 22.117,35.902 22.223,35.793C22.008,35.684 21.898,35.574 21.68,35.465C21.133,35.141 20.805,34.594 20.477,34.047L0.18,54.348C-0.038,54.562 -0.038,54.891 0.07,55.109L0.727,56.309C0.835,56.527 1.055,56.637 1.273,56.637C1.492,56.637 1.598,56.637 1.707,56.527Z" style="fill:#000000;"></path>
<path d="M25.824,35.902L28.008,98.215L20.371,98.215L22.332,41.25L23.535,40.27C24.188,39.723 24.406,38.957 24.188,38.195L23.754,36.449L23.973,36.23L24.188,36.23C24.844,36.23 25.391,36.121 25.824,35.902Z" style="fill:#000000;"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/logo-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logo-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -66,16 +66,29 @@ async function main() {
const page = await browser.newPage();
// 3. Inject Gatekeeper session bypassing auth screens
console.log(`\n🛡 Injecting Gatekeeper Session...`);
await page.setCookie({
name: 'klz_gatekeeper_session',
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',
httpOnly: true,
secure: targetUrl.startsWith('https://'),
});
// 3. Authenticate through Gatekeeper login form
console.log(`\n🛡 Authenticating through Gatekeeper...`);
try {
// Navigate to a protected page so Gatekeeper redirects us to the login screen
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Check if we landed on the Gatekeeper login page
const isGatekeeperPage = await page.$('input[name="password"]');
if (isGatekeeperPage) {
await page.type('input[name="password"]', gatekeeperPassword);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
page.click('button[type="submit"]'),
]);
console.log(`✅ Gatekeeper authentication successful!`);
} else {
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
}
} catch (err: any) {
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
await browser.close();
process.exit(1);
}
let hasErrors = false;
@@ -155,9 +168,38 @@ async function main() {
hasErrors = true;
}
// 5. Cleanup: Delete test submissions from Payload CMS
console.log(`\n🧹 Starting cleanup of test submissions...`);
try {
const apiUrl = `${targetUrl.replace(/\/$/, '')}/api/form-submissions`;
const searchUrl = `${apiUrl}?where[email][equals]=testing@mintel.me`;
// Fetch test submissions
const searchResponse = await axios.get(searchUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const testSubmissions = searchResponse.data.docs || [];
console.log(` Found ${testSubmissions.length} test submissions to clean up.`);
for (const doc of testSubmissions) {
try {
await axios.delete(`${apiUrl}/${doc.id}`, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
console.log(` ✅ Deleted submission: ${doc.id}`);
} catch (delErr: any) {
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`);
}
}
} catch (err: any) {
console.error(`❌ Cleanup failed: ${err.message}`);
// Don't mark the whole test as failed just because cleanup failed
}
await browser.close();
// 5. Evaluation
// 6. Evaluation
if (hasErrors) {
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
process.exit(1);

Some files were not shown because too many files have changed in this diff Show More