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
1437 lines
45 KiB
TypeScript
1437 lines
45 KiB
TypeScript
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 (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 (
|
||
<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 2–N: 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>
|
||
);
|
||
};
|