Files
klz-cables.com/lib/pdf-brochure.tsx
Marc Mintel d5dd66b832
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 1m57s
Build & Deploy / 🧪 QA (push) Failing after 2m3s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
chore(qa): resolve all lingering eslint warnings after branch merge
2026-03-03 13:54:04 +01:00

1437 lines
45 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 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<string | Buffer | undefined>;
messages?: Record<string, any>;
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 (
<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;
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>
);
};