Files
klz-cables.com/lib/pdf-brochure.tsx
2026-03-02 16:23:09 +01:00

807 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import {
Document,
Page,
View,
Text,
Image,
} from '@react-pdf/renderer';
// ─── Brand Tokens ───────────────────────────────────────────────────────────
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray600: '#4b5563',
gray900: '#111827',
};
const PAGE = { w: 595.28, h: 841.89 }; // A4 in points
const MARGIN = 56;
const CONTENT_W = PAGE.w - MARGIN * 2;
const HEADER_H = 52;
const FOOTER_H = 48;
const BODY_TOP = HEADER_H + 40;
const BODY_BOTTOM = FOOTER_H + 24;
// ─── Types ──────────────────────────────────────────────────────────────────
export interface BrochureProduct {
id: number;
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml?: string;
images: string[];
featuredImage: string | null;
sku: string;
slug: string;
categories: Array<{ name: string }>;
attributes: Array<{ name: string; options: string[] }>;
qrWebsite?: string | Buffer;
qrDatasheet?: string | Buffer;
}
export interface BrochureProps {
products: BrochureProduct[];
locale: 'en' | 'de';
companyInfo: {
tagline: string;
values: Array<{ title: string; description: string }>;
address: string;
phone: string;
email: string;
website: string;
};
logoBlack?: string | Buffer;
logoWhite?: string | Buffer;
introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
marketingSections?: Array<{
title: string;
subtitle: string;
description?: string;
items?: Array<{ title: string; description: string }>;
highlights?: Array<{ value: string; label: string }>;
pullQuote?: string;
}>;
galleryImages?: Array<string | Buffer | undefined>;
messages?: Record<string, any>;
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
}
// ─── Helpers ────────────────────────────────────────────────────────────────
const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const imgValid = (src?: string | Buffer): boolean => {
if (!src) return false;
if (Buffer.isBuffer(src)) return src.length > 0;
return true;
};
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
catalog: 'Kabelkatalog',
subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.',
about: 'Über uns',
toc: 'Inhalt',
overview: 'Übersicht',
application: 'Anwendungsbereich',
specs: 'Technische Daten',
contact: 'Kontakt',
qrWeb: 'Details',
qrPdf: 'PDF',
values: 'Unsere Werte',
edition: 'Ausgabe',
page: 'Seite',
property: 'Eigenschaft',
value: 'Wert',
other: 'Sonstige'
} : {
catalog: 'Cable Catalog',
subtitle: 'WE ENSURE THE CURRENT FLOWS WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.',
about: 'About Us',
toc: 'Contents',
overview: 'Overview',
application: 'Application',
specs: 'Technical Data',
contact: 'Contact',
qrWeb: 'Details',
qrPdf: 'PDF',
values: 'Our Values',
edition: 'Edition',
page: 'Page',
property: 'Property',
value: 'Value',
other: 'Other'
};
// ─── Rich Text ──────────────────────────────────────────────────────────────
const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => {
const paragraphs = children.split('\n\n').filter(p => p.trim());
return (
<View style={{ gap }}>
{paragraphs.map((para, pIdx) => {
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
let rem = para;
while (rem.length > 0) {
const bm = rem.match(/\*\*(.+?)\*\*/);
const im = rem.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const first = [bm, im].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
if (!first || first.index === undefined) { parts.push({ text: rem }); break; }
if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
parts.push({ text: first[1], bold: first[0].startsWith('**'), italic: !first[0].startsWith('**') });
rem = rem.substring(first.index + first[0].length);
}
return (
<Text key={pIdx} style={style}>
{parts.map((part, i) => (
<Text key={i} style={{
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: color || C.navyDeep } : {}),
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.green } : {}),
}}>{part.text}</Text>
))}
</Text>
);
})}
</View>
);
};
// ─── Shared Components ──────────────────────────────────────────────────────
// Thin brand bar at the top of every page
const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => (
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
paddingHorizontal: MARGIN, paddingBottom: 12,
}} fixed>
{logo ? <Image src={logo} style={{ width: 56 }} /> : <Text style={{ fontSize: 14, fontWeight: 700, color: dark ? C.white : C.navy }}>KLZ</Text>}
{right && <Text style={{ fontSize: 7, fontWeight: 700, color: dark ? C.gray400 : C.gray400, letterSpacing: 1.2, textTransform: 'uppercase' }}>{right}</Text>}
</View>
);
const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => (
<View style={{
position: 'absolute', bottom: 20, left: MARGIN, right: MARGIN,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
borderTopWidth: 0.5, borderTopColor: dark ? 'rgba(255,255,255,0.15)' : C.gray200, borderTopStyle: 'solid',
paddingTop: 8,
}} fixed>
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{left}</Text>
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{right}</Text>
</View>
);
// Green accent bar
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
// ─── FadeImage ─────────────────────────────────────────────────────────────
// Simulates a gradient fade at one edge using stacked opacity bands.
// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles.
//
// 'position' param: which edge fades INTO the page background
// 'bottom' → image visible at top, fades down into bgColor
// 'top' → image visible at bottom, fades up into bgColor
// 'right' → image on left side, fades right into bgColor
//
// The component must be placed ABSOLUTE (position: 'absolute') on the page.
const FadeImage: React.FC<{
src: string | Buffer;
top?: number; left?: number; right?: number; bottom?: number;
width: number | string;
height: number;
fadeEdge: 'bottom' | 'top' | 'right' | 'left';
fadeSize?: number; // how many points the fade spans
bgColor: string;
opacity?: number; // overall image darkness (01, applied via overlay)
}> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => {
const STEPS = 40; // High number of overlapping bands
const bands = Array.from({ length: STEPS }, (_, i) => {
// i=0 is the widest band reaching deepest into the image.
// i=STEPS-1 is the narrowest band right at the fade edge.
// Because they all anchor at the edge and overlap, their opacity compounds.
// We use an ease-in curve for distance to make the fade look natural.
const t = 1.0 / STEPS;
const easeDist = Math.pow((i + 1) / STEPS, 1.2);
const dist = fadeSize * easeDist;
const style: any = {
position: 'absolute',
backgroundColor: bgColor,
opacity: t,
};
// All bands anchor at the fade edge and extend inward by `dist`
if (fadeEdge === 'bottom') {
Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 });
} else if (fadeEdge === 'top') {
Object.assign(style, { left: 0, right: 0, height: dist, top: 0 });
} else if (fadeEdge === 'right') {
Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 });
} else {
Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 });
}
return style;
});
return (
<View style={{ position: 'absolute', top, left, right, bottom, width, height }}>
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{/* Overlay using bgColor to "wash out" / dilute the image */}
{opacity > 0 && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: bgColor, opacity }} />}
{/* Gradient fade bands */}
{bands.map((s, i) => <View key={i} style={s} />)}
</View>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// PAGE 1: COVER
// ═══════════════════════════════════════════════════════════════════════════
const CoverPage: React.FC<{
locale: 'en' | 'de';
introContent?: BrochureProps['introContent'];
logoWhite?: string | Buffer;
galleryImages?: Array<string | Buffer | undefined>;
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
const l = labels(locale);
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
const bg = galleryImages?.[0] || introContent?.heroImage;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
{/* Full-page background image with dark overlay */}
{imgValid(bg) && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<Image src={bg!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.82 }} />
</View>
)}
{!imgValid(bg) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
{/* Vertical accent stripe */}
<View style={{ position: 'absolute', top: 0, left: 0, width: 5, height: '40%', backgroundColor: C.green }} />
{/* Logo top-left */}
<View style={{ position: 'absolute', top: 56, left: MARGIN }}>
{imgValid(logoWhite) ? <Image src={logoWhite!} style={{ width: 120 }} /> : <Text style={{ fontSize: 24, fontWeight: 700, color: C.white }}>KLZ</Text>}
</View>
{/* Main title block — bottom third of page */}
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
{l.catalog}
</Text>
<Text style={{ fontSize: 12, color: C.gray300, lineHeight: 1.8, marginTop: 16, maxWidth: 340 }}>
{introContent?.excerpt || l.subtitle}
</Text>
</View>
{/* Bottom bar */}
<View style={{ position: 'absolute', bottom: 40, left: MARGIN, right: MARGIN, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ fontSize: 8, color: C.gray400, letterSpacing: 1, textTransform: 'uppercase' }}>{l.edition} {dateStr}</Text>
<Text style={{ fontSize: 9, fontWeight: 700, color: C.green }}>www.klz-cables.com</Text>
</View>
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// PAGES 2N: INFO PAGES (each marketing section = own page)
// ═══════════════════════════════════════════════════════════════════════════
const InfoPage: React.FC<{
section: NonNullable<BrochureProps['marketingSections']>[0];
image?: string | Buffer;
logoBlack?: string | Buffer;
logoWhite?: string | Buffer;
dark?: boolean;
imagePosition?: 'top' | 'bottom-half';
}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => {
const bg = dark ? C.navyDeep : C.white;
const textColor = dark ? C.gray300 : C.gray600;
const titleColor = dark ? C.white : C.navyDeep;
const boldColor = dark ? C.white : C.navyDeep;
const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack;
// Image at top: 240pt tall, content starts below via paddingTop
const IMG_TOP_H = 240;
const bodyTopWithImg = imagePosition === 'top' && imgValid(image)
? IMG_TOP_H + 24 // content starts below image
: BODY_TOP;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
{/* Absolute image — from page edge, fades into bg */}
{imgValid(image) && imagePosition === 'top' && (
<FadeImage
src={image!}
top={0} left={0} right={0}
width="100%" height={IMG_TOP_H}
fadeEdge="bottom" fadeSize={120}
bgColor={bg}
opacity={dark ? 0.85 : 0.9} // EXTREMELY high opacity of bgColor to make image incredibly subtle
/>
)}
{imgValid(image) && imagePosition === 'bottom-half' && (
<FadeImage
src={image!}
bottom={FOOTER_H + 20} left={0} right={0}
width="100%" height={340}
fadeEdge="top" fadeSize={140}
bgColor={bg}
opacity={dark ? 0.85 : 0.9} // Extremely subtle
/>
)}
{/* Header — on top of image */}
<Header logo={headerLogo} right="KLZ Cables" dark={dark} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
{/* Content — pushed below image when top-position */}
<View style={{ paddingTop: bodyTopWithImg }}>
{/* Label + Title */}
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
<Text style={{ fontSize: 24, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 16 }}>{section.title}</Text>
{/* Description */}
{section.description && (
<View style={{ marginBottom: 24 }}>
<RichText style={{ fontSize: 10, color: textColor, lineHeight: 1.7 }} gap={8} color={boldColor}>
{section.description}
</RichText>
</View>
)}
{/* Highlights */}
{section.highlights && section.highlights.length > 0 && (
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
{section.highlights.map((h, i) => (
<View key={i} style={{
flex: 1,
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
paddingVertical: 12, paddingHorizontal: 12,
}}>
<Text style={{ fontSize: 10, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
</View>
))}
</View>
)}
{/* Pull quote */}
{section.pullQuote && (
<View style={{
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
}}>
<Text style={{ fontSize: 12, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
{section.pullQuote}"
</Text>
</View>
)}
{/* Items — 2-column grid */}
{section.items && section.items.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
{section.items.map((item, i) => (
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
<Text style={{ fontSize: 9, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
<View style={{ width: 20, height: 1.5, backgroundColor: dark ? 'rgba(255,255,255,0.2)' : C.gray300, marginBottom: 6 }} />
<RichText style={{ fontSize: 8.5, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
{item.description}
</RichText>
</View>
))}
</View>
)}
</View>
</Page>
);
};
// About page (first info page)
const AboutPage: React.FC<{
locale: 'en' | 'de';
companyInfo: BrochureProps['companyInfo'];
logoBlack?: string | Buffer;
image?: string | Buffer;
messages?: Record<string, any>;
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
const l = labels(locale);
// Image at top: 200pt tall (smaller to leave more room for content)
const IMG_TOP_H = 200;
const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
// Pull directors content from messages if available
const team = messages?.Team || {};
const michael = team.michael;
const klaus = team.klaus;
const legacy = team.legacy;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
{/* Top-aligned image fading into white bottom */}
{imgValid(image) && (
<FadeImage
src={image!}
top={0} left={0} right={0}
width="100%" height={IMG_TOP_H}
fadeEdge="bottom" fadeSize={140}
bgColor={C.white}
opacity={0.92}
/>
)}
<Header logo={logoBlack} right="KLZ Cables" />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
{/* Content pushed below the fading image */}
<View style={{ paddingTop: bodyTopWithImg }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
<Text style={{ fontSize: 22, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 6 }}>KLZ Cables</Text>
<AccentBar />
<RichText style={{ fontSize: 10, color: C.gray900, lineHeight: 1.8 }} gap={8}>
{companyInfo.tagline}
</RichText>
{/* Company mission — makes immediately clear what KLZ does */}
<View style={{ marginTop: 12, marginBottom: 8 }}>
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
{locale === 'de'
? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.'
: 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'
}
</RichText>
</View>
{/* Directors — two-column */}
{(michael || klaus) && (
<View style={{ marginTop: 20 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
</Text>
<View style={{ flexDirection: 'row', gap: 20 }}>
{[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
<View key={i} style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
{p.photo && (
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
)}
<View>
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</Text>
</View>
</View>
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{p.data.description}</Text>
{p.data.quote && (
<View style={{ borderLeftWidth: 2, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 8 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
„{p.data.quote}“
</Text>
</View>
)}
</View>
))}
</View>
</View>
)}
{/* Values grid */}
<View style={{ marginTop: 20 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.values}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
{companyInfo.values.map((v, i) => (
<View key={i} style={{ width: '46%', marginBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<View style={{ width: 20, height: 20, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
</View>
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
</View>
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.5, paddingLeft: 28 }}>{v.description}</Text>
</View>
))}
</View>
</View>
</View>
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// TOC PAGE
// ═══════════════════════════════════════════════════════════════════════════
const TocPage: React.FC<{
products: BrochureProduct[];
locale: 'en' | 'de';
logoBlack?: string | Buffer;
productStartPage: number;
}> = ({ products, locale, logoBlack, productStartPage }) => {
const l = labels(locale);
// Group products by their first category
const categories: Array<{ name: string; products: Array<BrochureProduct & { startingPage: number }> }> = [];
let currentPageNum = productStartPage;
for (const p of products) {
const catName = p.categories[0]?.name || l.other;
let category = categories.find(c => c.name === catName);
if (!category) {
category = { name: catName, products: [] };
categories.push(category);
}
category.products.push({ ...p, startingPage: currentPageNum });
currentPageNum++;
}
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right={l.overview} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
<View style={{ paddingTop: BODY_TOP + 40 }}>
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
{l.catalog}
</Text>
{categories.map((cat, i) => (
<View key={i} style={{ marginBottom: 16 }} minPresenceAhead={40}>
{cat.name && (
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
{cat.name}
</Text>
)}
<View style={{ flexDirection: 'column', gap: 6 }}>
{cat.products.map((p, j) => (
<View key={j} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{p.name}</Text>
<View style={{ flex: 1, borderBottomWidth: 1, borderBottomColor: C.gray200, borderBottomStyle: 'dotted', marginHorizontal: 8, marginBottom: 3 }} />
<Text style={{ fontSize: 9, color: C.gray600 }}>{(p.startingPage || 0).toString().padStart(2, '0')}</Text>
</View>
))}
</View>
</View>
))}
</View>
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCT PAGES
// ═══════════════════════════════════════════════════════════════════════════
const ProductPage: React.FC<{
product: BrochureProduct;
locale: 'en' | 'de';
logoBlack?: string | Buffer;
}> = ({ product, locale, logoBlack }) => {
const l = labels(locale);
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right="KLZ Cables" />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
{/* Product image block reduced strictly to 110pt high */}
{product.featuredImage && (
<View style={{ height: 110, marginBottom: 20, marginHorizontal: -MARGIN }}>
<Image src={product.featuredImage as unknown as Buffer} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</View>
)}
{/* Labels & Name */}
{product.categories.length > 0 && (
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
{product.categories.map(c => c.name).join(' • ')}
</Text>
)}
<Text style={{ fontSize: 20, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 16 }}>{product.name}</Text>
{/* Description — full width */}
{product.descriptionHtml && (
<View style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 6 }}>{l.application}</Text>
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6 }} gap={6}>
{product.descriptionHtml}
</RichText>
</View>
)}
{/* Technical Data — full-width striped table */}
{product.attributes && product.attributes.length > 0 && (
<View style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
{/* Table header */}
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 5, paddingHorizontal: 10, marginBottom: 2 }}>
<View style={{ width: '50%' }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.value}</Text>
</View>
</View>
{product.attributes.map((attr, i) => (
<View key={i} style={{
flexDirection: 'row',
paddingVertical: 5, paddingHorizontal: 10,
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
}}>
<View style={{ width: '50%', paddingRight: 12 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 8, color: C.gray900 }}>{attr.options.join(', ')}</Text>
</View>
</View>
))}
</View>
)}
{/* QR Codes — horizontal row at bottom */}
{(product.qrWebsite || product.qrDatasheet) && (
<View style={{ flexDirection: 'row', gap: 24, marginTop: 8 }}>
{product.qrWebsite && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
<Image src={product.qrWebsite} style={{ width: 36, height: 36 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrWeb}</Text>
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
</View>
</View>
)}
{product.qrDatasheet && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
<Image src={product.qrDatasheet} style={{ width: 36, height: 36 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrPdf}</Text>
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
</View>
</View>
)}
</View>
)}
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// BACK COVER
// ═══════════════════════════════════════════════════════════════════════════
const BackCover: React.FC<{
companyInfo: BrochureProps['companyInfo'];
locale: 'en' | 'de';
logoWhite?: string | Buffer;
image?: string | Buffer;
}> = ({ companyInfo, locale, logoWhite, image }) => {
const l = labels(locale);
return (
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
{/* Background */}
{imgValid(image) && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.92 }} />
</View>
)}
{!imgValid(image) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: MARGIN }}>
{imgValid(logoWhite) ? (
<Image src={logoWhite!} style={{ width: 160, marginBottom: 40 }} />
) : (
<Text style={{ fontSize: 28, fontWeight: 700, color: C.white, letterSpacing: 3, textTransform: 'uppercase', marginBottom: 40 }}>KLZ CABLES</Text>
)}
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 40 }} />
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.contact}</Text>
<Text style={{ fontSize: 12, color: C.white, lineHeight: 1.8, textAlign: 'center', marginBottom: 20 }}>{companyInfo.address}</Text>
<Text style={{ fontSize: 12, color: C.white, marginBottom: 4 }}>{companyInfo.phone}</Text>
<Text style={{ fontSize: 12, color: C.gray300, marginBottom: 24 }}>{companyInfo.email}</Text>
<Text style={{ fontSize: 13, fontWeight: 700, color: C.green }}>{companyInfo.website}</Text>
</View>
<View style={{ position: 'absolute', bottom: 28, left: MARGIN, right: MARGIN, alignItems: 'center' }} fixed>
<Text style={{ fontSize: 8, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
</View>
</Page>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// DOCUMENT
// ═══════════════════════════════════════════════════════════════════════════
export const PDFBrochure: React.FC<BrochureProps> = ({
products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
}) => {
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
const numInfoPages = 1 + (marketingSections?.length || 0);
const productStartPage = 1 + numInfoPages + 1;
// Image assignment — each page gets a UNIQUE image, never repeating
// galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover
// TOC intentionally gets NO image (clean list page)
const totalGallery = galleryImages?.length || 0;
const backCoverImgIdx = totalGallery - 1;
// Section themes: alternate light/dark
const sectionThemes: Array<'light' | 'dark'> = [];
// imagePosition: alternate between top and bottom-half for variety
const imagePositions: Array<'top' | 'bottom-half'> = [];
if (marketingSections) {
for (let i = 0; i < marketingSections.length; i++) {
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
}
}
return (
<Document>
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
{/* About page — image[1] */}
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} directorPhotos={directorPhotos} />
{/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */}
{marketingSections?.map((section, i) => (
<InfoPage
key={`info-${i}`}
section={section}
image={galleryImages?.[i + 2]}
logoBlack={logoBlack}
logoWhite={logoWhite}
dark={sectionThemes[i] === 'dark'}
imagePosition={imagePositions[i]}
/>
))}
{/* TOC — no decorative image, clean list */}
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} />
{/* Products — each on its own page */}
{products.map(p => (
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
))}
{/* Back cover — last gallery image */}
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
</Document>
);
};