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 MARGIN = 56; 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; messages?: Record; directorPhotos?: { michael?: Buffer; klaus?: Buffer }; } // ─── Helpers ──────────────────────────────────────────────────────────────── const imgValid = (src?: string | Buffer): boolean => { if (!src) return false; if (Buffer.isBuffer(src)) return src.length > 0; return true; }; const labels = (locale: 'en' | 'de') => locale === 'de' ? { catalog: 'Kabelkatalog', subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.', about: 'Über uns', toc: 'Inhalt', overview: 'Übersicht', application: 'Anwendungsbereich', specs: 'Technische Daten', contact: 'Kontakt', qrWeb: 'Details', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'Seite', property: 'Eigenschaft', value: 'Wert', other: 'Sonstige', } : { catalog: 'Cable Catalog', subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.', about: 'About Us', toc: 'Contents', overview: 'Overview', application: 'Application', specs: 'Technical Data', contact: 'Contact', qrWeb: 'Details', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'Page', property: 'Property', value: 'Value', other: 'Other', }; // ─── Rich Text ────────────────────────────────────────────────────────────── const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color, }) => { const paragraphs = children.split('\n\n').filter((p) => p.trim()); return ( {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 PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark, }) => ( {left} {right} ); // Green accent bar const AccentBar = () => ( ); // ─── FadeImage ───────────────────────────────────────────────────────────── // Simulates a gradient fade at one edge using stacked opacity bands. // React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles. // // 'position' param: which edge fades INTO the page background // 'bottom' → image visible at top, fades down into bgColor // 'top' → image visible at bottom, fades up into bgColor // 'right' → image on left side, fades right into bgColor // // The component must be placed ABSOLUTE (position: 'absolute') on the page. const FadeImage: React.FC<{ src: string | Buffer; top?: number; left?: number; right?: number; bottom?: number; width: number | string; height: number; fadeEdge: 'bottom' | 'top' | 'right' | 'left'; fadeSize?: number; // how many points the fade spans bgColor: string; opacity?: number; // overall image darkness (0–1, applied via overlay) }> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0, }) => { const STEPS = 40; // High number of overlapping bands const bands = Array.from({ length: STEPS }, (_, i) => { // i=0 is the widest band reaching deepest into the image. // i=STEPS-1 is the narrowest band right at the fade edge. // Because they all anchor at the edge and overlap, their opacity compounds. // We use an ease-in curve for distance to make the fade look natural. const t = 1.0 / STEPS; const easeDist = Math.pow((i + 1) / STEPS, 1.2); const dist = fadeSize * easeDist; const style: any = { position: 'absolute', backgroundColor: bgColor, opacity: t, }; // All bands anchor at the fade edge and extend inward by `dist` if (fadeEdge === 'bottom') { Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 }); } else if (fadeEdge === 'top') { Object.assign(style, { left: 0, right: 0, height: dist, top: 0 }); } else if (fadeEdge === 'right') { Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 }); } else { Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 }); } return style; }); return ( {/* Overlay using bgColor to "wash out" / dilute the image */} {opacity > 0 && ( )} {/* Gradient fade bands */} {bands.map((s, i) => ( ))} ); }; // ═══════════════════════════════════════════════════════════════════════════ // PAGE 1: COVER // ═══════════════════════════════════════════════════════════════════════════ const CoverPage: React.FC<{ 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; return ( {/* Full-page background image with dark overlay */} {imgValid(bg) && ( )} {!imgValid(bg) && ( )} {/* Vertical accent stripe */} {/* Logo top-left */} {imgValid(logoWhite) ? ( ) : ( KLZ )} {/* Main title block — bottom third of page */} {l.catalog} {introContent?.excerpt || l.subtitle} {/* Bottom bar */} {l.edition} {dateStr} www.klz-cables.com ); }; // ═══════════════════════════════════════════════════════════════════════════ // PAGES 2–N: INFO PAGES (each marketing section = own page) // ═══════════════════════════════════════════════════════════════════════════ const InfoPage: React.FC<{ 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; // 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' && ( )} {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} ))} )} ); }; // 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, companyInfo, logoBlack, image, messages, directorPhotos }) => { const l = labels(locale); // Image at top: 200pt tall (smaller to leave more room for content) const IMG_TOP_H = 200; const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP; // Pull directors content from messages if available const team = messages?.Team || {}; const michael = team.michael; const klaus = team.klaus; return ( {/* Top-aligned image fading into white bottom */} {imgValid(image) && ( )}
{/* Content pushed below the fading image */} {l.about} KLZ Cables {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.'} {/* 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} {p.data.role} {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} ))} ); }; // ═══════════════════════════════════════════════════════════════════════════ // TOC PAGE // ═══════════════════════════════════════════════════════════════════════════ const TocPage: React.FC<{ products: BrochureProduct[]; locale: 'en' | 'de'; logoBlack?: string | Buffer; productStartPage: number; }> = ({ products, locale, logoBlack, productStartPage }) => { const l = labels(locale); // Group products by their first category const categories: Array<{ name: string; products: Array; }> = []; 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 (
{l.catalog} {categories.map((cat, i) => ( {cat.name && ( {cat.name} )} {cat.products.map((p, j) => ( {p.name} {(p.startingPage || 0).toString().padStart(2, '0')} ))} ))} ); }; // ═══════════════════════════════════════════════════════════════════════════ // PRODUCT PAGES // ═══════════════════════════════════════════════════════════════════════════ const ProductPage: React.FC<{ product: BrochureProduct; locale: 'en' | 'de'; logoBlack?: string | Buffer; }> = ({ product, locale, logoBlack }) => { const l = labels(locale); return (
{/* Product image block reduced strictly to 110pt high */} {product.featuredImage && ( )} {/* 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} {locale === 'de' ? 'Produktseite' : 'Product Page'} )} {product.qrDatasheet && ( {l.qrPdf} {locale === 'de' ? 'Datenblatt' : 'Datasheet'} )} )} ); }; // ═══════════════════════════════════════════════════════════════════════════ // 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 ( {/* Background */} {imgValid(image) && ( )} {!imgValid(image) && ( )} {imgValid(logoWhite) ? ( ) : ( KLZ CABLES )} {l.contact} {companyInfo.address} {companyInfo.phone} {companyInfo.email} {companyInfo.website} © {new Date().getFullYear()} KLZ Cables GmbH ); }; // ═══════════════════════════════════════════════════════════════════════════ // DOCUMENT // ═══════════════════════════════════════════════════════════════════════════ export const PDFBrochure: React.FC = ({ products, locale, companyInfo, introContent, marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos, }) => { // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1) const numInfoPages = 1 + (marketingSections?.length || 0); const productStartPage = 1 + numInfoPages + 1; // Image assignment — each page gets a UNIQUE image, never repeating // galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover // TOC intentionally gets NO image (clean list page) const totalGallery = galleryImages?.length || 0; const backCoverImgIdx = totalGallery - 1; // Section themes: alternate light/dark const sectionThemes: Array<'light' | 'dark'> = []; // imagePosition: alternate between top and bottom-half for variety const imagePositions: Array<'top' | 'bottom-half'> = []; if (marketingSections) { for (let i = 0; i < marketingSections.length; i++) { sectionThemes.push(i % 2 === 1 ? 'dark' : 'light'); imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half'); } } return ( {/* About page — image[1] */} {/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */} {marketingSections?.map((section, i) => ( ))} {/* TOC — no decorative image, clean list */} {/* Products — each on its own page */} {products.map((p) => ( ))} {/* Back cover — last gallery image */} ); };