From d5dd66b8321cde2e6595069031bb317def272a5c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 3 Mar 2026 13:54:04 +0100 Subject: [PATCH] chore(qa): resolve all lingering eslint warnings after branch merge --- app/[locale]/[slug]/page.tsx | 2 +- app/[locale]/blog/page.tsx | 2 +- app/[locale]/contact/page.tsx | 2 +- app/[locale]/not-found.tsx | 2 +- app/[locale]/products/[...slug]/page.tsx | 2 - app/[locale]/team/page.tsx | 2 +- components/Lightbox.tsx | 6 +- components/ObfuscatedEmail.tsx | 1 + components/ObfuscatedPhone.tsx | 1 + components/PayloadRichText.tsx | 4 +- components/analytics/TrackedLink.tsx | 4 +- lib/pdf-brochure.tsx | 1932 ++++++++++++++-------- lib/utils/technical.ts | 166 +- next-env.d.ts | 2 +- src/payload/components/Icon.tsx | 17 +- src/payload/components/Logo.tsx | 17 +- 16 files changed, 1396 insertions(+), 766 deletions(-) diff --git a/app/[locale]/[slug]/page.tsx b/app/[locale]/[slug]/page.tsx index b17c167b..569c6e3e 100644 --- a/app/[locale]/[slug]/page.tsx +++ b/app/[locale]/[slug]/page.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from 'next/navigation'; import { Container, Badge, Heading } from '@/components/ui'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Metadata } from 'next'; -import { getPageBySlug, getAllPages } from '@/lib/pages'; +import { getPageBySlug } from '@/lib/pages'; import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import PayloadRichText from '@/components/PayloadRichText'; import { SITE_URL } from '@/lib/schema'; diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index 2144da2c..9c7e2c9a 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -14,7 +14,7 @@ interface BlogIndexProps { }>; } -export async function generateMetadata({ params }: BlogIndexProps) { +export async function generateMetadata({ params }: BlogIndexProps): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Blog.meta' }); return { diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index afab2e98..9a78760c 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -5,7 +5,7 @@ import { Container, Heading, Section } from '@/components/ui'; import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { SITE_URL } from '@/lib/schema'; -import { getOGImageMetadata } from '@/lib/metadata'; + import { Suspense } from 'react'; import ContactMap from '@/components/ContactMap'; import ObfuscatedEmail from '@/components/ObfuscatedEmail'; diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx index c153adcb..6e85f82f 100644 --- a/app/[locale]/not-found.tsx +++ b/app/[locale]/not-found.tsx @@ -72,7 +72,7 @@ export default async function NotFound() { } suggestedUrl = '/' + pathParts.join('/'); } - } catch (e) { + } catch { // Ignore Payload errors in 404 } } diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index d58e4892..8aee5b23 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -1,9 +1,7 @@ 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'; diff --git a/app/[locale]/team/page.tsx b/app/[locale]/team/page.tsx index d97773f6..81c6c1c8 100644 --- a/app/[locale]/team/page.tsx +++ b/app/[locale]/team/page.tsx @@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Metadata } from 'next'; import JsonLd from '@/components/JsonLd'; import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; -import { Section, Container, Heading, Badge, Button } from '@/components/ui'; +import { Section, Container, Heading, Badge } from '@/components/ui'; import Image from 'next/image'; import Reveal from '@/components/Reveal'; import Gallery from '@/components/team/Gallery'; diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index e168c75e..a6990372 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh const previousFocusRef = useRef(null); useEffect(() => { - setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect + setMounted(true); return () => setMounted(false); }, []); @@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh if (photoParam !== null) { const index = parseInt(photoParam, 10); if (!isNaN(index) && index >= 0 && index < images.length) { - setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect + setCurrentIndex(index); } } }, [searchParams, images.length]); @@ -139,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh if (!mounted) return null; return createPortal( - import('@/lib/framer-features').then(res => res.default)}> + import('@/lib/framer-features').then((res) => res.default)}> {isOpen && (
{ + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/components/ObfuscatedPhone.tsx b/components/ObfuscatedPhone.tsx index 0db10d57..6df6e418 100644 --- a/components/ObfuscatedPhone.tsx +++ b/components/ObfuscatedPhone.tsx @@ -16,6 +16,7 @@ export default function ObfuscatedPhone({ phone, className = '', children }: Obf const [mounted, setMounted] = useState(false); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx index 4e8753b2..8b20a649 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -786,8 +786,8 @@ const jsxConverters: JSXConverters = { ); }, - imageGallery: ({ node }: any) => , - 'block-imageGallery': ({ node }: any) => , + imageGallery: () => , + 'block-imageGallery': () => , categoryGrid: ({ node }: any) => { const cats = node.fields.categories || []; return ( diff --git a/components/analytics/TrackedLink.tsx b/components/analytics/TrackedLink.tsx index 1abe0ade..8b9d2b69 100644 --- a/components/analytics/TrackedLink.tsx +++ b/components/analytics/TrackedLink.tsx @@ -28,13 +28,13 @@ export default function TrackedLink({ }: TrackedLinkProps) { const { trackEvent } = useAnalytics(); - const handleClick = (e: React.MouseEvent) => { + const handleClick = () => { try { trackEvent(eventName, { href, ...eventProperties, }); - } catch (_e) { + } catch { // Analytics tracking should not block navigation, so we catch and ignore errors. } if (onClick) onClick(); diff --git a/lib/pdf-brochure.tsx b/lib/pdf-brochure.tsx index c8ba3d9e..25878d44 100644 --- a/lib/pdf-brochure.tsx +++ b/lib/pdf-brochure.tsx @@ -1,32 +1,24 @@ import * as React from 'react'; -import { - Document, - Page, - View, - Text, - Image, -} from '@react-pdf/renderer'; +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', + 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; @@ -35,155 +27,245 @@ 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; + 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; - messages?: Record; - directorPhotos?: { michael?: Buffer; klaus?: Buffer }; + 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; + messages?: Record; + directorPhotos?: { michael?: Buffer; klaus?: Buffer }; } // ─── Helpers ──────────────────────────────────────────────────────────────── -const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); - const imgValid = (src?: string | Buffer): boolean => { - if (!src) return false; - if (Buffer.isBuffer(src)) return src.length > 0; - return true; + if (!src) return false; + if (Buffer.isBuffer(src)) return src.length > 0; + return true; }; -const labels = (locale: 'en' | 'de') => locale === 'de' ? { - catalog: 'Kabelkatalog', - subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.', - about: 'Über uns', - toc: 'Inhalt', - overview: 'Übersicht', - application: 'Anwendungsbereich', - specs: 'Technische Daten', - contact: 'Kontakt', - qrWeb: 'Details', - qrPdf: 'PDF', - values: 'Unsere Werte', - edition: 'Ausgabe', - page: 'Seite', - property: 'Eigenschaft', - value: 'Wert', - other: 'Sonstige' -} : { - catalog: 'Cable Catalog', - subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.', - about: 'About Us', - toc: 'Contents', - overview: 'Overview', - application: 'Application', - specs: 'Technical Data', - contact: 'Contact', - qrWeb: 'Details', - qrPdf: 'PDF', - values: 'Our Values', - edition: 'Edition', - page: 'Page', - property: 'Property', - value: 'Value', - other: 'Other' -}; +const labels = (locale: 'en' | 'de') => + locale === 'de' + ? { + catalog: 'Kabelkatalog', + subtitle: + 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.', + about: 'Über uns', + toc: 'Inhalt', + overview: 'Übersicht', + application: 'Anwendungsbereich', + specs: 'Technische Daten', + contact: 'Kontakt', + qrWeb: 'Details', + qrPdf: 'PDF', + values: 'Unsere Werte', + edition: 'Ausgabe', + page: 'Seite', + property: 'Eigenschaft', + value: 'Wert', + other: 'Sonstige', + } + : { + catalog: 'Cable Catalog', + subtitle: + 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.', + about: 'About Us', + toc: 'Contents', + overview: 'Overview', + application: 'Application', + specs: 'Technical Data', + contact: 'Contact', + qrWeb: 'Details', + qrPdf: 'PDF', + values: 'Our Values', + edition: 'Edition', + page: 'Page', + property: 'Property', + value: 'Value', + other: 'Other', + }; // ─── Rich Text ────────────────────────────────────────────────────────────── -const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => { - const paragraphs = children.split('\n\n').filter(p => p.trim()); - return ( - - {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(/(? (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 ( - - {parts.map((part, i) => ( - {part.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 ( + + {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(/(? (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 ( + + {parts.map((part, i) => ( + + {part.text} + + ))} + + ); + })} + + ); }; // ─── 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 }) => ( - - {logo ? : KLZ} - {right && {right}} - +const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ + logo, + right, + dark, +}) => ( + + {logo ? ( + + ) : ( + KLZ + )} + {right && ( + + {right} + + )} + ); -const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => ( - - {left} - {right} - +const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ + left, + right, + dark, +}) => ( + + + {left} + + + {right} + + ); // Green accent bar -const AccentBar = () => ; +const AccentBar = () => ( + +); // ─── FadeImage ───────────────────────────────────────────────────────────── // Simulates a gradient fade at one edge using stacked opacity bands. @@ -197,55 +279,84 @@ const AccentBar = () => = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => { - const STEPS = 40; // High number of overlapping bands + src: string | Buffer; + top?: number; + left?: number; + right?: number; + bottom?: number; + width: number | string; + height: number; + fadeEdge: 'bottom' | 'top' | 'right' | 'left'; + fadeSize?: number; // how many points the fade spans + bgColor: string; + opacity?: number; // overall image darkness (0–1, applied via overlay) +}> = ({ + src, + top, + left, + right, + bottom, + width, + height, + fadeEdge, + fadeSize = 120, + bgColor, + opacity = 0, +}) => { + const STEPS = 40; // High number of overlapping bands - const bands = Array.from({ length: STEPS }, (_, i) => { - // i=0 is the widest band reaching deepest into the image. - // i=STEPS-1 is the narrowest band right at the fade edge. - // Because they all anchor at the edge and overlap, their opacity compounds. - // We use an ease-in curve for distance to make the fade look natural. - const t = 1.0 / STEPS; - const easeDist = Math.pow((i + 1) / STEPS, 1.2); - const dist = fadeSize * easeDist; + const bands = Array.from({ length: STEPS }, (_, i) => { + // i=0 is the widest band reaching deepest into the image. + // i=STEPS-1 is the narrowest band right at the fade edge. + // Because they all anchor at the edge and overlap, their opacity compounds. + // We use an ease-in curve for distance to make the fade look natural. + const t = 1.0 / STEPS; + const easeDist = Math.pow((i + 1) / STEPS, 1.2); + const dist = fadeSize * easeDist; - const style: any = { + const style: any = { + position: 'absolute', + backgroundColor: bgColor, + opacity: t, + }; + + // All bands anchor at the fade edge and extend inward by `dist` + if (fadeEdge === 'bottom') { + Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 }); + } else if (fadeEdge === 'top') { + Object.assign(style, { left: 0, right: 0, height: dist, top: 0 }); + } else if (fadeEdge === 'right') { + Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 }); + } else { + Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 }); + } + + return style; + }); + + return ( + + + {/* Overlay using bgColor to "wash out" / dilute the image */} + {opacity > 0 && ( + - - {/* Overlay using bgColor to "wash out" / dilute the image */} - {opacity > 0 && } - {/* Gradient fade bands */} - {bands.map((s, i) => )} - - ); + opacity, + }} + /> + )} + {/* Gradient fade bands */} + {bands.map((s, i) => ( + + ))} + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -253,52 +364,114 @@ const FadeImage: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ const CoverPage: React.FC<{ - locale: 'en' | 'de'; - introContent?: BrochureProps['introContent']; - logoWhite?: string | Buffer; - galleryImages?: Array; + locale: 'en' | 'de'; + introContent?: BrochureProps['introContent']; + logoWhite?: string | Buffer; + galleryImages?: Array; }> = ({ 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; + 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 ( - - {/* Full-page background image with dark overlay */} - {imgValid(bg) && ( - - - - - )} - {!imgValid(bg) && } + return ( + + {/* Full-page background image with dark overlay */} + {imgValid(bg) && ( + + + + + )} + {!imgValid(bg) && ( + + )} - {/* Vertical accent stripe */} - + {/* Vertical accent stripe */} + - {/* Logo top-left */} - - {imgValid(logoWhite) ? : KLZ} - + {/* Logo top-left */} + + {imgValid(logoWhite) ? ( + + ) : ( + KLZ + )} + - {/* Main title block — bottom third of page */} - - - - {l.catalog} - - - {introContent?.excerpt || l.subtitle} - - + {/* Main title block — bottom third of page */} + + + + {l.catalog} + + + {introContent?.excerpt || l.subtitle} + + - {/* Bottom bar */} - - {l.edition} {dateStr} - www.klz-cables.com - - - ); + {/* Bottom bar */} + + + {l.edition} {dateStr} + + www.klz-cables.com + + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -306,227 +479,424 @@ const CoverPage: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ const InfoPage: React.FC<{ - section: NonNullable[0]; - image?: string | Buffer; - logoBlack?: string | Buffer; - logoWhite?: string | Buffer; - dark?: boolean; - imagePosition?: 'top' | 'bottom-half'; + section: NonNullable[0]; + image?: string | Buffer; + logoBlack?: string | Buffer; + logoWhite?: string | Buffer; + dark?: boolean; + imagePosition?: 'top' | 'bottom-half'; }> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => { - const bg = dark ? C.navyDeep : C.white; - const textColor = dark ? C.gray300 : C.gray600; - const titleColor = dark ? C.white : C.navyDeep; - const boldColor = dark ? C.white : C.navyDeep; - const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack; + const bg = dark ? C.navyDeep : C.white; + const textColor = dark ? C.gray300 : C.gray600; + const titleColor = dark ? C.white : C.navyDeep; + const boldColor = dark ? C.white : C.navyDeep; + const headerLogo = dark ? logoWhite || logoBlack : logoBlack; - // Image at top: 240pt tall, content starts below via paddingTop - const IMG_TOP_H = 240; - const bodyTopWithImg = imagePosition === 'top' && imgValid(image) - ? IMG_TOP_H + 24 // content starts below image - : BODY_TOP; + // Image at top: 240pt tall, content starts below via paddingTop + const IMG_TOP_H = 240; + const bodyTopWithImg = + imagePosition === 'top' && imgValid(image) + ? IMG_TOP_H + 24 // content starts below image + : BODY_TOP; - return ( - - {/* Absolute image — from page edge, fades into bg */} - {imgValid(image) && imagePosition === 'top' && ( - + {/* Absolute image — from page edge, fades into bg */} + {imgValid(image) && imagePosition === 'top' && ( + + )} + {imgValid(image) && imagePosition === 'bottom-half' && ( + + )} + + {/* Header — on top of image */} +
+ + + {/* Content — pushed below image when top-position */} + + {/* Label + Title */} + + {section.subtitle} + + + {section.title} + + + {/* Description */} + {section.description && ( + + + {section.description} + + + )} + + {/* Highlights */} + {section.highlights && section.highlights.length > 0 && ( + + {section.highlights.map((h, i) => ( + + + {h.value} + + + {h.label} + + + ))} + + )} + + {/* Pull quote */} + {section.pullQuote && ( + + + „{section.pullQuote}" + + + )} + + {/* Items — 2-column grid */} + {section.items && section.items.length > 0 && ( + + {section.items.map((item, i) => ( + + + {item.title} + + - )} - {imgValid(image) && imagePosition === 'bottom-half' && ( - - )} - - {/* Header — on top of image */} -
- - - {/* Content — pushed below image when top-position */} - - - {/* Label + Title */} - {section.subtitle} - {section.title} - - {/* Description */} - {section.description && ( - - - {section.description} - - - )} - - {/* Highlights */} - {section.highlights && section.highlights.length > 0 && ( - - {section.highlights.map((h, i) => ( - - {h.value} - {h.label} - - ))} - - )} - - {/* Pull quote */} - {section.pullQuote && ( - - - „{section.pullQuote}" - - - )} - - {/* Items — 2-column grid */} - {section.items && section.items.length > 0 && ( - - {section.items.map((item, i) => ( - - {item.title} - - - {item.description} - - - ))} - - )} - - - ); + + {item.description} + + + ))} + + )} + + + ); }; // About page (first info page) const AboutPage: React.FC<{ - locale: 'en' | 'de'; - companyInfo: BrochureProps['companyInfo']; - logoBlack?: string | Buffer; - image?: string | Buffer; - messages?: Record; - directorPhotos?: { michael?: Buffer; klaus?: Buffer }; + locale: 'en' | 'de'; + companyInfo: BrochureProps['companyInfo']; + logoBlack?: string | Buffer; + image?: string | Buffer; + messages?: Record; + directorPhotos?: { michael?: Buffer; klaus?: Buffer }; }> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => { - const l = labels(locale); + const l = labels(locale); - // Image at top: 200pt tall (smaller to leave more room for content) - const IMG_TOP_H = 200; - const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP; + // Image at top: 200pt tall (smaller to leave more room for content) + const IMG_TOP_H = 200; + const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP; - // Pull directors content from messages if available - const team = messages?.Team || {}; - const michael = team.michael; - const klaus = team.klaus; - const legacy = team.legacy; + // Pull directors content from messages if available + const team = messages?.Team || {}; + const michael = team.michael; + const klaus = team.klaus; - return ( - - {/* Top-aligned image fading into white bottom */} - {imgValid(image) && ( - - )} + return ( + + {/* Top-aligned image fading into white bottom */} + {imgValid(image) && ( + + )} -
- +
+ - {/* Content pushed below the fading image */} - - {l.about} - KLZ Cables - + {/* Content pushed below the fading image */} + + + {l.about} + + + KLZ Cables + + - - {companyInfo.tagline} - + + {companyInfo.tagline} + - {/* Company mission — makes immediately clear what KLZ does */} - - - {locale === 'de' - ? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.' - : 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.' - } - - + {/* Company mission — makes immediately clear what KLZ does */} + + + {locale === 'de' + ? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.' + : 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'} + + - {/* Directors — two-column */} - {(michael || klaus) && ( - - - {locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'} + {/* Directors — two-column */} + {(michael || klaus) && ( + + + {locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'} + + + {[ + { data: michael, photo: directorPhotos?.michael }, + { data: klaus, photo: directorPhotos?.klaus }, + ] + .filter((p) => p.data) + .map((p, i) => ( + + + {p.photo && ( + + )} + + + {p.data.name} - - {[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => ( - - - {p.photo && ( - - )} - - {p.data.name} - {p.data.role} - - - {p.data.description} - {p.data.quote && ( - - - „{p.data.quote}“ - - - )} - - ))} - + + {p.data.role} + + - )} - - {/* Values grid */} - - {l.values} - - {companyInfo.values.map((v, i) => ( - - - - 0{i + 1} - - {v.title} - - {v.description} - - ))} - - + + {p.data.description} + + {p.data.quote && ( + + + „{p.data.quote}“ + + + )} + + ))} - - ); + + )} + + {/* Values grid */} + + + {l.values} + + + {companyInfo.values.map((v, i) => ( + + + + 0{i + 1} + + {v.title} + + + {v.description} + + + ))} + + + + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -534,58 +904,104 @@ const AboutPage: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ const TocPage: React.FC<{ - products: BrochureProduct[]; - locale: 'en' | 'de'; - logoBlack?: string | Buffer; - productStartPage: number; + products: BrochureProduct[]; + locale: 'en' | 'de'; + logoBlack?: string | Buffer; + productStartPage: number; }> = ({ products, locale, logoBlack, productStartPage }) => { - const l = labels(locale); + const l = labels(locale); - // Group products by their first category - const categories: Array<{ name: string; products: Array }> = []; - let currentPageNum = productStartPage; - for (const p of products) { - const catName = p.categories[0]?.name || l.other; - let category = categories.find(c => c.name === catName); - if (!category) { - category = { name: catName, products: [] }; - categories.push(category); - } - category.products.push({ ...p, startingPage: currentPageNum }); - currentPageNum++; + // Group products by their first category + const categories: Array<{ + name: string; + products: Array; + }> = []; + let currentPageNum = productStartPage; + for (const p of products) { + const catName = p.categories[0]?.name || l.other; + let category = categories.find((c) => c.name === catName); + if (!category) { + category = { name: catName, products: [] }; + categories.push(category); } + category.products.push({ ...p, startingPage: currentPageNum }); + currentPageNum++; + } - return ( - -
- + return ( + +
+ - - - {l.catalog} - + + + {l.catalog} + - {categories.map((cat, i) => ( - - {cat.name && ( - - {cat.name} - - )} - - {cat.products.map((p, j) => ( - - {p.name} - - {(p.startingPage || 0).toString().padStart(2, '0')} - - ))} - - - ))} + {categories.map((cat, i) => ( + + {cat.name && ( + + {cat.name} + + )} + + {cat.products.map((p, j) => ( + + {p.name} + + + {(p.startingPage || 0).toString().padStart(2, '0')} + + + ))} - - ); + + ))} + + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -593,105 +1009,223 @@ const TocPage: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ const ProductPage: React.FC<{ - product: BrochureProduct; - locale: 'en' | 'de'; - logoBlack?: string | Buffer; + product: BrochureProduct; + locale: 'en' | 'de'; + logoBlack?: string | Buffer; }> = ({ product, locale, logoBlack }) => { - const l = labels(locale); + const l = labels(locale); - return ( - -
- + return ( + +
+ - {/* Product image block reduced strictly to 110pt high */} - {product.featuredImage && ( - - - - )} + {/* Product image block reduced strictly to 110pt high */} + {product.featuredImage && ( + + + + )} - {/* Labels & Name */} - {product.categories.length > 0 && ( - - {product.categories.map(c => c.name).join(' • ')} + {/* Labels & Name */} + {product.categories.length > 0 && ( + + {product.categories.map((c) => c.name).join(' • ')} + + )} + + {product.name} + + + {/* Description — full width */} + {product.descriptionHtml && ( + + + {l.application} + + + {product.descriptionHtml} + + + )} + + {/* Technical Data — full-width striped table */} + {product.attributes && product.attributes.length > 0 && ( + + + {l.specs} + + + {/* Table header */} + + + + {l.property} + + + + + {l.value} + + + + + {product.attributes.map((attr, i) => ( + + + {attr.name} + + + {attr.options.join(', ')} + + + ))} + + )} + + {/* QR Codes — horizontal row at bottom */} + {(product.qrWebsite || product.qrDatasheet) && ( + + {product.qrWebsite && ( + + + + + + + {l.qrWeb} - )} - {product.name} - - {/* Description — full width */} - {product.descriptionHtml && ( - - {l.application} - - {product.descriptionHtml} - - - )} - - {/* Technical Data — full-width striped table */} - {product.attributes && product.attributes.length > 0 && ( - - {l.specs} - - {/* Table header */} - - - {l.property} - - - {l.value} - - - - {product.attributes.map((attr, i) => ( - - - {attr.name} - - - {attr.options.join(', ')} - - - ))} - - )} - - {/* QR Codes — horizontal row at bottom */} - {(product.qrWebsite || product.qrDatasheet) && ( - - {product.qrWebsite && ( - - - - - - {l.qrWeb} - {locale === 'de' ? 'Produktseite' : 'Product Page'} - - - )} - {product.qrDatasheet && ( - - - - - - {l.qrPdf} - {locale === 'de' ? 'Datenblatt' : 'Datasheet'} - - - )} - - )} - - ); + + {locale === 'de' ? 'Produktseite' : 'Product Page'} + + + + )} + {product.qrDatasheet && ( + + + + + + + {l.qrPdf} + + + {locale === 'de' ? 'Datenblatt' : 'Datasheet'} + + + + )} + + )} + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -699,47 +1233,113 @@ const ProductPage: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ const BackCover: React.FC<{ - companyInfo: BrochureProps['companyInfo']; - locale: 'en' | 'de'; - logoWhite?: string | Buffer; - image?: string | Buffer; + companyInfo: BrochureProps['companyInfo']; + locale: 'en' | 'de'; + logoWhite?: string | Buffer; + image?: string | Buffer; }> = ({ companyInfo, locale, logoWhite, image }) => { - const l = labels(locale); + const l = labels(locale); - return ( - - {/* Background */} - {imgValid(image) && ( - - - - - )} - {!imgValid(image) && } + return ( + + {/* Background */} + {imgValid(image) && ( + + + + + )} + {!imgValid(image) && ( + + )} - - {imgValid(logoWhite) ? ( - - ) : ( - KLZ CABLES - )} + + {imgValid(logoWhite) ? ( + + ) : ( + + KLZ CABLES + + )} - + - {l.contact} - {companyInfo.address} + + {l.contact} + + + {companyInfo.address} + - {companyInfo.phone} - {companyInfo.email} + {companyInfo.phone} + + {companyInfo.email} + - {companyInfo.website} - + {companyInfo.website} + - - © {new Date().getFullYear()} KLZ Cables GmbH - - - ); + + + © {new Date().getFullYear()} KLZ Cables GmbH + + + + ); }; // ═══════════════════════════════════════════════════════════════════════════ @@ -747,60 +1347,90 @@ const BackCover: React.FC<{ // ═══════════════════════════════════════════════════════════════════════════ export const PDFBrochure: React.FC = ({ - products, locale, companyInfo, introContent, - marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos, + products, + locale, + companyInfo, + introContent, + marketingSections, + logoBlack, + logoWhite, + galleryImages, + messages, + directorPhotos, }) => { - // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1) - const numInfoPages = 1 + (marketingSections?.length || 0); - const productStartPage = 1 + numInfoPages + 1; + // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1) + const numInfoPages = 1 + (marketingSections?.length || 0); + const productStartPage = 1 + numInfoPages + 1; - // Image assignment — each page gets a UNIQUE image, never repeating - // galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover - // TOC intentionally gets NO image (clean list page) - const totalGallery = galleryImages?.length || 0; - const backCoverImgIdx = totalGallery - 1; + // Image assignment — each page gets a UNIQUE image, never repeating + // galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover + // TOC intentionally gets NO image (clean list page) + const totalGallery = galleryImages?.length || 0; + const backCoverImgIdx = totalGallery - 1; - // Section themes: alternate light/dark - const sectionThemes: Array<'light' | 'dark'> = []; - // imagePosition: alternate between top and bottom-half for variety - const imagePositions: Array<'top' | 'bottom-half'> = []; - if (marketingSections) { - for (let i = 0; i < marketingSections.length; i++) { - sectionThemes.push(i % 2 === 1 ? 'dark' : 'light'); - imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half'); - } + // Section themes: alternate light/dark + const sectionThemes: Array<'light' | 'dark'> = []; + // imagePosition: alternate between top and bottom-half for variety + const imagePositions: Array<'top' | 'bottom-half'> = []; + if (marketingSections) { + for (let i = 0; i < marketingSections.length; i++) { + sectionThemes.push(i % 2 === 1 ? 'dark' : 'light'); + imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half'); } + } - return ( - - + return ( + + - {/* About page — image[1] */} - + {/* About page — image[1] */} + - {/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */} - {marketingSections?.map((section, i) => ( - - ))} + {/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */} + {marketingSections?.map((section, i) => ( + + ))} - {/* TOC — no decorative image, clean list */} - + {/* TOC — no decorative image, clean list */} + - {/* Products — each on its own page */} - {products.map(p => ( - - ))} + {/* Products — each on its own page */} + {products.map((p) => ( + + ))} - {/* Back cover — last gallery image */} - - - ); + {/* Back cover — last gallery image */} + + + ); }; diff --git a/lib/utils/technical.ts b/lib/utils/technical.ts index 31b5478f..438cf5d9 100644 --- a/lib/utils/technical.ts +++ b/lib/utils/technical.ts @@ -4,10 +4,10 @@ */ export interface FormattedTechnicalValue { - original: string; - isList: boolean; - parts: string[]; - displayValue: string; + original: string; + isList: boolean; + parts: string[]; + displayValue: string; } /** @@ -15,92 +15,90 @@ export interface FormattedTechnicalValue { * 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: '' }; + 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()); } + } - const str = String(value).trim(); + // If no parts found yet, try comma + if (parts.length === 0 && str.includes(', ')) { + parts = str.split(', ').map((p) => p.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()); + // 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) { + 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); + } } - } + } - // If no parts found yet, try comma - if (parts.length === 0 && str.includes(', ')) { - parts = str.split(', ').map(p => p.trim()); - } + // 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(' / -'); - // 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 { + return { original: str, - isList: false, - parts: [str], - displayValue: 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, + }; } diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/payload/components/Icon.tsx b/src/payload/components/Icon.tsx index 97300f70..59b08f4a 100644 --- a/src/payload/components/Icon.tsx +++ b/src/payload/components/Icon.tsx @@ -1,12 +1,13 @@ +/* eslint-disable @next/next/no-img-element */ import React from 'react'; export default function Icon() { - return ( - KLZ - ); + return ( + KLZ + ); } diff --git a/src/payload/components/Logo.tsx b/src/payload/components/Logo.tsx index 5cd93306..8ccfcfea 100644 --- a/src/payload/components/Logo.tsx +++ b/src/payload/components/Logo.tsx @@ -1,12 +1,13 @@ +/* eslint-disable @next/next/no-img-element */ import React from 'react'; export default function Logo() { - return ( - KLZ Cables - ); + return ( + KLZ Cables + ); }