diff --git a/lib/pdf-brochure.tsx b/lib/pdf-brochure.tsx index c88d3708..e71f99ba 100644 --- a/lib/pdf-brochure.tsx +++ b/lib/pdf-brochure.tsx @@ -5,20 +5,17 @@ import { View, Text, Image, - StyleSheet, } from '@react-pdf/renderer'; -// ─── Brand Colors ─────────────────────────────────────────────────────────── +// ─── Brand Tokens ─────────────────────────────────────────────────────────── const C = { - primary: '#001a4d', // Navy - primaryDark: '#000d26', // Deepest Navy - saturated: '#0117bf', - accent: '#4da612', // Green - accentLight: '#e8f5d8', - black: '#000000', + navy: '#001a4d', + navyDeep: '#000d26', + green: '#4da612', + greenLight: '#e8f5d8', white: '#FFFFFF', - gray050: '#f8f9fa', + offWhite: '#f8f9fa', gray100: '#f3f4f6', gray200: '#e5e7eb', gray300: '#d1d5db', @@ -27,13 +24,13 @@ const C = { gray900: '#111827', }; -// ─── Spacing Scale ────────────────────────────────────────────────────────── - -const S = { xs: 4, sm: 8, md: 16, lg: 24, xl: 40, xxl: 56 } as const; - -const M = { h: 72, bottom: 96 } as const; -const HEADER_H = 64; -const PAGE_TOP_PADDING = 110; +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 ────────────────────────────────────────────────────────────────── @@ -80,55 +77,54 @@ export interface BrochureProps { // ─── Helpers ──────────────────────────────────────────────────────────────── -const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); +const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); -const L = (locale: 'en' | 'de') => locale === 'de' ? { - catalog: 'Produktkatalog', subtitle: 'Hochwertige Stromkabel · Mittelspannungslösungen · Solarkabel', +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 · Medium Voltage Solutions · Solar Cables', + 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', }; -// ─── Text tokens ──────────────────────────────────────────────────────────── +// ─── Rich Text ────────────────────────────────────────────────────────────── -const T = { - label: (d: boolean) => ({ fontSize: 9, fontWeight: 700, color: C.accent, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1.2 }), - sectionTitle: (d: boolean) => ({ fontSize: 24, fontWeight: 700, color: d ? C.white : C.primaryDark, letterSpacing: -0.5 }), - body: (d: boolean) => ({ fontSize: 10, color: d ? C.gray300 : C.gray600, lineHeight: 1.7 }), - bodyLead: (d: boolean) => ({ fontSize: 13, color: d ? C.white : C.gray900, lineHeight: 1.8 }), - bodyBold: (d: boolean) => ({ fontSize: 10, fontWeight: 700, color: d ? C.white : C.primaryDark }), - caption: (d: boolean) => ({ fontSize: 8, color: d ? C.gray400 : C.gray400, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1 }), -}; - -// ─── Rich Text (supports **bold** and *italic*) ──────────────────────────── - -const RichText: React.FC<{ children: string; style?: any; paragraphGap?: number; isDark?: boolean; asParagraphs?: boolean }> = ({ children, style = {}, paragraphGap = 8, isDark = false, asParagraphs = true }) => { - const paragraphs = asParagraphs ? children.split('\n\n').filter(p => p.trim()) : [children]; +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 remaining = para; - while (remaining.length > 0) { - const boldMatch = remaining.match(/\*\*(.+?)\*\*/); - const italicMatch = remaining.match(/(? (a!.index || 0) - (b!.index || 0))[0]; - if (!firstMatch || firstMatch.index === undefined) { parts.push({ text: remaining }); break; } - if (firstMatch.index > 0) parts.push({ text: remaining.substring(0, firstMatch.index) }); - parts.push({ text: firstMatch[1], bold: firstMatch[0].startsWith('**'), italic: !firstMatch[0].startsWith('**') }); - remaining = remaining.substring(firstMatch.index + firstMatch[0].length); + 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} ))} @@ -138,412 +134,357 @@ const RichText: React.FC<{ children: string; style?: any; paragraphGap?: number; ); }; -// ─── Reusable Components ──────────────────────────────────────────────────── +// ─── Shared Components ────────────────────────────────────────────────────── -const FixedHeader: React.FC<{ logoWhite?: string | Buffer; logoBlack?: string | Buffer; rightText?: string; isDark?: boolean }> = ({ logoWhite, logoBlack, rightText, isDark }) => { - const logo = isDark ? (logoWhite || logoBlack) : logoBlack; - return ( - - {logo ? : KLZ} - {rightText && {rightText}} - - ); -}; - -const Footer: React.FC<{ left: string; right: string; logoWhite?: string | Buffer; logoBlack?: string | Buffer; isDark?: boolean }> = ({ left, right, logoWhite, logoBlack, isDark }) => { - const logo = isDark ? (logoWhite || logoBlack) : logoBlack; - return ( - - - {logo && } - {left} - - {right} - - ); -}; - -const SectionHeading: React.FC<{ label?: string; title: string, isDark: boolean }> = ({ label, title, isDark }) => ( - - {label && {label}} - {title} - - -); - -// Pull-quote callout block -const PullQuote: React.FC<{ quote: string, isDark: boolean }> = ({ quote, isDark }) => ( +// Thin brand bar at the top of every page +const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => ( - - „{quote}" - + 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 ? : KLZ} + {right && {right}} ); -// Stat highlight boxes -const HighlightRow: React.FC<{ highlights: Array<{ value: string; label: string }>, isDark: boolean }> = ({ highlights, isDark }) => ( - - {highlights.map((h, i) => ( - - {h.value} - {h.label} - - ))} +const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => ( + + {left} + {right} ); -// Magazine Edge-to-Edge Image -// By using negative horizontal margin (-M.h) matching the parent padding, it touches the very edges. -// By using negative vertical margin matching the vertical padding, it touches the top or bottom of the colored block! -const MagazineImage: React.FC<{ src: string | Buffer; height?: number; position: 'top' | 'bottom' | 'middle'; isDark?: boolean }> = ({ src, height = 260, position, isDark }) => { - if (Buffer.isBuffer(src) && src.length === 0) return null; +// Green accent bar +const AccentBar = () => ; - const marginTop = position === 'top' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl); - const marginBottom = position === 'bottom' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl); +// ═══════════════════════════════════════════════════════════════════════════ +// 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 ( - - - {isDark && ( - + + {/* 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 + + ); }; -// Magazine Block wrapper -const MagazineSection: React.FC<{ +// ═══════════════════════════════════════════════════════════════════════════ +// PAGES 2–N: INFO PAGES (each marketing section = own page) +// ═══════════════════════════════════════════════════════════════════════════ + +const InfoPage: React.FC<{ section: NonNullable[0]; image?: string | Buffer; - theme: 'white' | 'gray' | 'dark'; - imagePosition: 'top' | 'bottom' | 'middle'; -}> = ({ section, image, theme, imagePosition }) => { - const isDark = theme === 'dark'; - const bgColor = theme === 'white' ? C.white : (theme === 'gray' ? C.gray050 : C.primaryDark); + 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 ( - - {image && imagePosition === 'top' && } + +
+ - + {/* Full-width image at top */} + {imgValid(image) && ( + + + {dark && } + + )} + {/* Label + Title */} + {section.subtitle} + {section.title} + + + {/* Description */} {section.description && ( - - {section.description} - + + + {section.description} + + )} - {image && imagePosition === 'middle' && } - + {/* Highlights — horizontal stat cards */} {section.highlights && section.highlights.length > 0 && ( - + + {section.highlights.map((h, i) => ( + + {h.value} + {h.label} + + ))} + )} - {section.pullQuote && } + {/* Pull quote */} + {section.pullQuote && ( + + + „{section.pullQuote}" + + + )} + {/* Items — 2-column grid with accent bars */} {section.items && section.items.length > 0 && ( - + {section.items.map((item, i) => ( - - - {item.title} - + + + {item.title} + {item.description} ))} )} - - {image && imagePosition === 'bottom' && } - - ); -}; - -// ─── Cover Page ───────────────────────────────────────────────────────────── - -const CoverPage: React.FC<{ - locale: 'en' | 'de'; - introContent?: BrochureProps['introContent']; - logoBlack?: string | Buffer; - logoWhite?: string | Buffer; - galleryImages?: Array; -}> = ({ locale, introContent, logoWhite, logoBlack, galleryImages }) => { - const l = L(locale); - const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' }); - const bgImage = galleryImages?.[0] || introContent?.heroImage; - const logo = logoWhite || logoBlack; - - return ( - - {bgImage && ( - - - - - )} - - - - - {logo ? : KLZ} - - - - - - {l.catalog} - - - {introContent?.excerpt || l.subtitle} - - - - - {l.edition} {dateStr} - www.klz-cables.com - - ); }; -// ─── Info Flow ────────────────────────────────────────────────────────────── - -const InfoFlow: React.FC<{ +// About page (first info page, special layout with values grid) +const AboutPage: React.FC<{ locale: 'en' | 'de'; companyInfo: BrochureProps['companyInfo']; - marketingSections?: BrochureProps['marketingSections']; logoBlack?: string | Buffer; - galleryImages?: Array; -}> = ({ locale, companyInfo, marketingSections, logoBlack, galleryImages }) => { - const l = L(locale); + image?: string | Buffer; +}> = ({ locale, companyInfo, logoBlack, image }) => { + const l = labels(locale); return ( - - -