feature/excel #1

Open
mmintel wants to merge 84 commits from feature/excel into main
4 changed files with 423 additions and 463 deletions
Showing only changes of commit 17ebde407e - Show all commits

View File

@@ -5,20 +5,17 @@ import {
View, View,
Text, Text,
Image, Image,
StyleSheet,
} from '@react-pdf/renderer'; } from '@react-pdf/renderer';
// ─── Brand Colors ─────────────────────────────────────────────────────────── // ─── Brand Tokens ───────────────────────────────────────────────────────────
const C = { const C = {
primary: '#001a4d', // Navy navy: '#001a4d',
primaryDark: '#000d26', // Deepest Navy navyDeep: '#000d26',
saturated: '#0117bf', green: '#4da612',
accent: '#4da612', // Green greenLight: '#e8f5d8',
accentLight: '#e8f5d8',
black: '#000000',
white: '#FFFFFF', white: '#FFFFFF',
gray050: '#f8f9fa', offWhite: '#f8f9fa',
gray100: '#f3f4f6', gray100: '#f3f4f6',
gray200: '#e5e7eb', gray200: '#e5e7eb',
gray300: '#d1d5db', gray300: '#d1d5db',
@@ -27,13 +24,13 @@ const C = {
gray900: '#111827', gray900: '#111827',
}; };
// ─── Spacing Scale ────────────────────────────────────────────────────────── const PAGE = { w: 595.28, h: 841.89 }; // A4 in points
const MARGIN = 56;
const S = { xs: 4, sm: 8, md: 16, lg: 24, xl: 40, xxl: 56 } as const; const CONTENT_W = PAGE.w - MARGIN * 2;
const HEADER_H = 52;
const M = { h: 72, bottom: 96 } as const; const FOOTER_H = 48;
const HEADER_H = 64; const BODY_TOP = HEADER_H + 40;
const PAGE_TOP_PADDING = 110; const BODY_BOTTOM = FOOTER_H + 24;
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -80,55 +77,54 @@ export interface BrochureProps {
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const L = (locale: 'en' | 'de') => locale === 'de' ? { const imgValid = (src?: string | Buffer): boolean => {
catalog: 'Produktkatalog', subtitle: 'Hochwertige Stromkabel · Mittelspannungslösungen · Solarkabel', if (!src) return false;
if (Buffer.isBuffer(src)) return src.length > 0;
return true;
};
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
catalog: 'Produktkatalog',
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht', about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt', application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.', qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
property: 'Eigenschaft', value: 'Wert',
} : { } : {
catalog: 'Product Catalog', subtitle: 'High-Quality Power Cables · Medium Voltage Solutions · Solar Cables', catalog: 'Product Catalog',
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview', about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
application: 'Application', specs: 'Technical Data', contact: 'Contact', application: 'Application', specs: 'Technical Data', contact: 'Contact',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.', qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
property: 'Property', value: 'Value',
}; };
// ─── Text tokens ──────────────────────────────────────────────────────────── // ─── Rich Text ──────────────────────────────────────────────────────────────
const T = { const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => {
label: (d: boolean) => ({ fontSize: 9, fontWeight: 700, color: C.accent, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1.2 }), const paragraphs = children.split('\n\n').filter(p => p.trim());
sectionTitle: (d: boolean) => ({ fontSize: 24, fontWeight: 700, color: d ? C.white : C.primaryDark, letterSpacing: -0.5 }),
body: (d: boolean) => ({ fontSize: 10, color: d ? C.gray300 : C.gray600, lineHeight: 1.7 }),
bodyLead: (d: boolean) => ({ fontSize: 13, color: d ? C.white : C.gray900, lineHeight: 1.8 }),
bodyBold: (d: boolean) => ({ fontSize: 10, fontWeight: 700, color: d ? C.white : C.primaryDark }),
caption: (d: boolean) => ({ fontSize: 8, color: d ? C.gray400 : C.gray400, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1 }),
};
// ─── Rich Text (supports **bold** and *italic*) ────────────────────────────
const RichText: React.FC<{ children: string; style?: any; paragraphGap?: number; isDark?: boolean; asParagraphs?: boolean }> = ({ children, style = {}, paragraphGap = 8, isDark = false, asParagraphs = true }) => {
const paragraphs = asParagraphs ? children.split('\n\n').filter(p => p.trim()) : [children];
return ( return (
<View style={{ gap: paragraphGap }}> <View style={{ gap }}>
{paragraphs.map((para, pIdx) => { {paragraphs.map((para, pIdx) => {
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = []; const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
let remaining = para; let rem = para;
while (remaining.length > 0) { while (rem.length > 0) {
const boldMatch = remaining.match(/\*\*(.+?)\*\*/); const bm = rem.match(/\*\*(.+?)\*\*/);
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/); const im = rem.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const firstMatch = [boldMatch, italicMatch].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0]; const first = [bm, im].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
if (!firstMatch || firstMatch.index === undefined) { parts.push({ text: remaining }); break; } if (!first || first.index === undefined) { parts.push({ text: rem }); break; }
if (firstMatch.index > 0) parts.push({ text: remaining.substring(0, firstMatch.index) }); if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
parts.push({ text: firstMatch[1], bold: firstMatch[0].startsWith('**'), italic: !firstMatch[0].startsWith('**') }); parts.push({ text: first[1], bold: first[0].startsWith('**'), italic: !first[0].startsWith('**') });
remaining = remaining.substring(firstMatch.index + firstMatch[0].length); rem = rem.substring(first.index + first[0].length);
} }
return ( return (
<Text key={pIdx} style={style}> <Text key={pIdx} style={style}>
{parts.map((part, i) => ( {parts.map((part, i) => (
<Text key={i} style={{ <Text key={i} style={{
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: isDark ? C.white : C.primaryDark } : {}), ...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: color || C.navyDeep } : {}),
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.accent } : {}), ...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.green } : {}),
}}>{part.text}</Text> }}>{part.text}</Text>
))} ))}
</Text> </Text>
@@ -138,412 +134,357 @@ const RichText: React.FC<{ children: string; style?: any; paragraphGap?: number;
); );
}; };
// ─── Reusable Components ──────────────────────────────────────────────────── // ─── Shared Components ──────────────────────────────────────────────────────
const FixedHeader: React.FC<{ logoWhite?: string | Buffer; logoBlack?: string | Buffer; rightText?: string; isDark?: boolean }> = ({ logoWhite, logoBlack, rightText, isDark }) => { // Thin brand bar at the top of every page
const logo = isDark ? (logoWhite || logoBlack) : logoBlack; const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => (
return (
<View style={{ <View style={{
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H, position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
paddingHorizontal: M.h, paddingTop: 24, paddingBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: MARGIN, paddingBottom: 12,
borderBottomWidth: 0.5, borderBottomColor: isDark ? 'rgba(255,255,255,0.1)' : C.gray300, borderBottomStyle: 'solid',
backgroundColor: isDark ? C.primaryDark : C.white,
}} fixed> }} fixed>
{logo ? <Image src={logo} style={{ width: 64 }} /> : <Text style={{ fontSize: 16, fontWeight: 700, color: isDark ? C.white : C.primaryDark }}>KLZ</Text>} {logo ? <Image src={logo} style={{ width: 56 }} /> : <Text style={{ fontSize: 14, fontWeight: 700, color: dark ? C.white : C.navy }}>KLZ</Text>}
{rightText && <Text style={{ fontSize: 8, fontWeight: 700, color: isDark ? C.white : C.primary, letterSpacing: 0.8, textTransform: 'uppercase' }}>{rightText}</Text>} {right && <Text style={{ fontSize: 7, fontWeight: 700, color: dark ? C.gray400 : C.gray400, letterSpacing: 1.2, textTransform: 'uppercase' }}>{right}</Text>}
</View> </View>
); );
};
const Footer: React.FC<{ left: string; right: string; logoWhite?: string | Buffer; logoBlack?: string | Buffer; isDark?: boolean }> = ({ left, right, logoWhite, logoBlack, isDark }) => { const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => (
const logo = isDark ? (logoWhite || logoBlack) : logoBlack; <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 }} />;
// ═══════════════════════════════════════════════════════════════════════════
// PAGE 1: COVER
// ═══════════════════════════════════════════════════════════════════════════
const CoverPage: React.FC<{
locale: 'en' | 'de';
introContent?: BrochureProps['introContent'];
logoWhite?: string | Buffer;
galleryImages?: Array<string | Buffer>;
}> = ({ 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 ( return (
<View style={{ <Page size="A4" style={{ fontFamily: 'Helvetica' }}>
position: 'absolute', bottom: 40, left: M.h, right: M.h, {/* Full-page background image with dark overlay */}
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', {imgValid(bg) && (
paddingTop: 12, <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
borderTopWidth: 0.5, borderTopColor: isDark ? 'rgba(255,255,255,0.1)' : C.gray300, borderTopStyle: 'solid', <Image src={bg!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
}} fixed> <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.82 }} />
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{logo && <Image src={logo} style={{ width: 40, height: 'auto' }} />}
<Text style={T.caption(!!isDark)}>{left}</Text>
</View> </View>
<Text style={T.caption(!!isDark)}>{right}</Text> )}
</View> {!imgValid(bg) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
);
};
const SectionHeading: React.FC<{ label?: string; title: string, isDark: boolean }> = ({ label, title, isDark }) => ( {/* Vertical accent stripe */}
<View style={{ marginBottom: S.lg }} minPresenceAhead={120}> <View style={{ position: 'absolute', top: 0, left: 0, width: 5, height: '40%', backgroundColor: C.green }} />
{label && <Text style={{ ...T.label(isDark), marginBottom: S.sm }}>{label}</Text>}
<Text style={{ ...T.sectionTitle(isDark), marginBottom: S.md }}>{title}</Text>
<View style={{ width: 48, height: 4, backgroundColor: C.accent }} />
</View>
);
// Pull-quote callout block {/* Logo top-left */}
const PullQuote: React.FC<{ quote: string, isDark: boolean }> = ({ quote, isDark }) => ( <View style={{ position: 'absolute', top: 56, left: MARGIN }}>
<View style={{ {imgValid(logoWhite) ? <Image src={logoWhite!} style={{ width: 120 }} /> : <Text style={{ fontSize: 24, fontWeight: 700, color: C.white }}>KLZ</Text>}
marginVertical: S.xl, </View>
paddingLeft: S.lg,
borderLeftWidth: 4, borderLeftColor: C.accent, borderLeftStyle: 'solid', {/* Main title block — bottom third of page */}
}}> <View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
<Text style={{ fontSize: 16, fontWeight: 700, color: isDark ? C.white : C.primaryDark, lineHeight: 1.5, letterSpacing: -0.2 }}> <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
{quote}" <Text style={{ fontSize: 56, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -1, lineHeight: 1 }}>
{l.catalog}
</Text>
<Text style={{ fontSize: 14, color: C.gray300, lineHeight: 1.8, marginTop: 20, maxWidth: 340 }}>
{introContent?.excerpt || l.subtitle}
</Text> </Text>
</View> </View>
);
// Stat highlight boxes {/* Bottom bar */}
const HighlightRow: React.FC<{ highlights: Array<{ value: string; label: string }>, isDark: boolean }> = ({ highlights, isDark }) => ( <View style={{ position: 'absolute', bottom: 40, left: MARGIN, right: MARGIN, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flexDirection: 'row', gap: S.md, marginVertical: S.lg }}> <Text style={{ fontSize: 8, color: C.gray400, letterSpacing: 1, textTransform: 'uppercase' }}>{l.edition} {dateStr}</Text>
{highlights.map((h, i) => ( <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;
dark?: boolean;
}> = ({ section, image, logoBlack, dark }) => {
const bg = dark ? C.navyDeep : C.white;
const textColor = dark ? C.gray300 : C.gray600;
const titleColor = dark ? C.white : C.navyDeep;
const boldColor = dark ? C.white : C.navyDeep;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
{/* Full-width image at top */}
{imgValid(image) && (
<View style={{ height: 200, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{dark && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.15 }} />}
</View>
)}
{/* Label + Title */}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
<Text style={{ fontSize: 28, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 8 }}>{section.title}</Text>
<AccentBar />
{/* Description */}
{section.description && (
<View style={{ marginBottom: 24 }}>
<RichText style={{ fontSize: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
{section.description}
</RichText>
</View>
)}
{/* Highlights — horizontal stat cards */}
{section.highlights && section.highlights.length > 0 && (
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
{section.highlights.map((h, i) => (
<View key={i} style={{ <View key={i} style={{
flex: 1, flex: 1,
backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : C.gray050, backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
borderLeftWidth: 3, borderLeftColor: C.accent, borderLeftStyle: 'solid', borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
paddingVertical: S.md, paddingHorizontal: S.md, paddingVertical: 14, paddingHorizontal: 14,
alignItems: 'flex-start',
}}> }}>
<Text style={{ fontSize: 18, fontWeight: 700, color: isDark ? C.white : C.primaryDark, marginBottom: 4 }}>{h.value}</Text> <Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
<Text style={{ fontSize: 9, color: isDark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text> <Text style={{ fontSize: 8, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
</View> </View>
))} ))}
</View> </View>
); )}
// Magazine Edge-to-Edge Image {/* Pull quote */}
// By using negative horizontal margin (-M.h) matching the parent padding, it touches the very edges. {section.pullQuote && (
// By using negative vertical margin matching the vertical padding, it touches the top or bottom of the colored block!
const MagazineImage: React.FC<{ src: string | Buffer; height?: number; position: 'top' | 'bottom' | 'middle'; isDark?: boolean }> = ({ src, height = 260, position, isDark }) => {
if (Buffer.isBuffer(src) && src.length === 0) return null;
const marginTop = position === 'top' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl);
const marginBottom = position === 'bottom' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl);
return (
<View style={{ <View style={{
marginHorizontal: -M.h, borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
height, paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
marginTop,
marginBottom,
position: 'relative'
}}> }}>
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <Text style={{ fontSize: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
{isDark && ( {section.pullQuote}"
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.2 }} /> </Text>
)}
</View> </View>
);
};
// Magazine Block wrapper
const MagazineSection: React.FC<{
section: NonNullable<BrochureProps['marketingSections']>[0];
image?: string | Buffer;
theme: 'white' | 'gray' | 'dark';
imagePosition: 'top' | 'bottom' | 'middle';
}> = ({ section, image, theme, imagePosition }) => {
const isDark = theme === 'dark';
const bgColor = theme === 'white' ? C.white : (theme === 'gray' ? C.gray050 : C.primaryDark);
return (
<View style={{
marginHorizontal: -M.h,
paddingHorizontal: M.h,
paddingVertical: S.xxl,
backgroundColor: bgColor,
}} wrap={true}>
{image && imagePosition === 'top' && <MagazineImage src={image} height={280} position="top" isDark={isDark} />}
<SectionHeading label={section.subtitle} title={section.title} isDark={isDark} />
{section.description && (
<RichText style={T.bodyLead(isDark)} paragraphGap={S.md} isDark={isDark}>
{section.description}
</RichText>
)} )}
{image && imagePosition === 'middle' && <MagazineImage src={image} height={220} position="middle" isDark={isDark} />} {/* Items — 2-column grid with accent bars */}
{section.highlights && section.highlights.length > 0 && (
<HighlightRow highlights={section.highlights} isDark={isDark} />
)}
{section.pullQuote && <PullQuote quote={section.pullQuote} isDark={isDark} />}
{section.items && section.items.length > 0 && ( {section.items && section.items.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: S.xl, marginTop: S.xl }}> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
{section.items.map((item, i) => ( {section.items.map((item, i) => (
<View key={i} style={{ width: '45%', marginBottom: S.sm }} minPresenceAhead={60}> <View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
<View style={{ width: 24, height: 2, backgroundColor: C.accent, marginBottom: S.sm }} /> <View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
<Text style={{ ...T.bodyBold(isDark), fontSize: 11, marginBottom: 4 }}>{item.title}</Text> <Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
<RichText style={{ ...T.body(isDark) }} asParagraphs={false} isDark={isDark}> <RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
{item.description} {item.description}
</RichText> </RichText>
</View> </View>
))} ))}
</View> </View>
)} )}
{image && imagePosition === 'bottom' && <MagazineImage src={image} height={320} position="bottom" isDark={isDark} />}
</View>
);
};
// ─── Cover Page ─────────────────────────────────────────────────────────────
const CoverPage: React.FC<{
locale: 'en' | 'de';
introContent?: BrochureProps['introContent'];
logoBlack?: string | Buffer;
logoWhite?: string | Buffer;
galleryImages?: Array<string | Buffer>;
}> = ({ locale, introContent, logoWhite, logoBlack, galleryImages }) => {
const l = L(locale);
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
const bgImage = galleryImages?.[0] || introContent?.heroImage;
const logo = logoWhite || logoBlack;
return (
<Page size="A4" style={{ backgroundColor: C.primaryDark, fontFamily: 'Helvetica' }}>
{bgImage && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<Image src={bgImage} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.85 }} />
</View>
)}
<View style={{ position: 'absolute', top: 0, left: 0, width: 6, height: 320, backgroundColor: C.accent }} />
<View style={{ flex: 1, paddingHorizontal: M.h }}>
<View style={{ marginTop: 80 }}>
{logo ? <Image src={logo} style={{ width: 140 }} /> : <Text style={{ fontSize: 28, fontWeight: 700, color: C.white }}>KLZ</Text>}
</View>
<View style={{ marginTop: 180 }}>
<View style={{ width: 48, height: 4, backgroundColor: C.accent, borderRadius: 2, marginBottom: S.xl }} />
<Text style={{ fontSize: 48, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: 0.5, lineHeight: 1.1, marginBottom: S.md }}>
{l.catalog}
</Text>
<Text style={{ fontSize: 16, color: C.gray300, lineHeight: 1.6, maxWidth: 360 }}>
{introContent?.excerpt || l.subtitle}
</Text>
</View>
<View style={{ position: 'absolute', bottom: S.xxl, left: M.h, right: M.h, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ fontSize: 10, color: C.gray400, textTransform: 'uppercase', letterSpacing: 1 }}>{l.edition} {dateStr}</Text>
<Text style={{ fontSize: 10, color: C.white, fontWeight: 700 }}>www.klz-cables.com</Text>
</View>
</View>
</Page> </Page>
); );
}; };
// ─── Info Flow ────────────────────────────────────────────────────────────── // About page (first info page, special layout with values grid)
const AboutPage: React.FC<{
const InfoFlow: React.FC<{
locale: 'en' | 'de'; locale: 'en' | 'de';
companyInfo: BrochureProps['companyInfo']; companyInfo: BrochureProps['companyInfo'];
marketingSections?: BrochureProps['marketingSections'];
logoBlack?: string | Buffer; logoBlack?: string | Buffer;
galleryImages?: Array<string | Buffer>; image?: string | Buffer;
}> = ({ locale, companyInfo, marketingSections, logoBlack, galleryImages }) => { }> = ({ locale, companyInfo, logoBlack, image }) => {
const l = L(locale); const l = labels(locale);
return ( return (
<Page size="A4" wrap style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}> <Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<FixedHeader logoBlack={logoBlack} rightText="KLZ Cables" isDark={false} /> <Header logo={logoBlack} right="KLZ Cables" />
<Footer left="KLZ Cables" right={companyInfo.website} logoBlack={logoBlack} isDark={false} /> <PageFooter left="KLZ Cables" right="www.klz-cables.com" />
<View style={{ paddingHorizontal: M.h }}> {/* Full-width image at top */}
{imgValid(image) && (
{/* ── About KLZ ── */} <View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
<View style={{ <Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
marginHorizontal: -M.h, paddingHorizontal: M.h,
paddingTop: S.xxl, paddingBottom: S.xl,
backgroundColor: C.white,
}}>
<View style={{ flexDirection: 'row', gap: S.xl }}>
<View style={{ flex: 1.5 }}>
<SectionHeading label={l.about} title="KLZ Cables" isDark={false} />
<RichText style={{ ...T.bodyLead(false), fontSize: 14, lineHeight: 1.6 }} paragraphGap={S.md} isDark={false}>
{companyInfo.tagline}
</RichText>
</View>
<View style={{ flex: 1 }}>
{galleryImages?.[1] && (
<View style={{ width: '100%', height: 240, borderLeftWidth: 4, borderLeftColor: C.accent, borderLeftStyle: 'solid' }}>
<Image src={galleryImages[1]} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View> </View>
)} )}
</View>
</View>
<View style={{ marginTop: S.xxl }}> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
<Text style={{ ...T.label(false), marginBottom: S.md }}>{l.values}</Text> <Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: S.lg }}> <AccentBar />
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
{companyInfo.tagline}
</RichText>
{/* Values grid */}
<View style={{ marginTop: 32 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16 }}>{l.values}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
{companyInfo.values.map((v, i) => ( {companyInfo.values.map((v, i) => (
<View key={i} style={{ width: '47%', marginBottom: S.md }} minPresenceAhead={40}> <View key={i} style={{ width: '46%', marginBottom: 8 }}>
<Text style={{ ...T.bodyBold(false), fontSize: 11, marginBottom: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<Text style={{ color: C.accent }}>0{i + 1}</Text> {'\u00A0'} {v.title} <View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
</Text> <Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
<Text style={{ ...T.body(false), fontSize: 9 }}>{v.description}</Text> </View>
<Text style={{ fontSize: 11, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
</View>
<Text style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6, paddingLeft: 38 }}>{v.description}</Text>
</View> </View>
))} ))}
</View> </View>
</View> </View>
</View>
{/* ── Marketing Sections ── */}
{marketingSections?.map((section, sIdx) => {
const themes: Array<'white' | 'gray' | 'dark'> = ['gray', 'dark', 'white', 'gray', 'white', 'dark'];
const imagePositions: Array<'top' | 'bottom' | 'middle'> = ['bottom', 'top', 'bottom', 'middle', 'middle', 'top'];
const theme = themes[sIdx % themes.length];
const pos = imagePositions[sIdx % imagePositions.length];
const img = galleryImages?.[sIdx + 2];
return (
<MagazineSection
key={`m-${sIdx}`}
section={section}
image={img}
theme={theme}
imagePosition={pos}
/>
);
})}
</View>
</Page> </Page>
); );
}; };
// ─── TOC Page ─────────────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════
// TOC PAGE
// ═══════════════════════════════════════════════════════════════════════════
const TocPage: React.FC<{ const TocPage: React.FC<{
products: BrochureProduct[]; products: BrochureProduct[];
locale: 'en' | 'de'; locale: 'en' | 'de';
logoBlack?: string | Buffer; logoBlack?: string | Buffer;
productStartPage: number; productStartPage: number;
galleryImages?: Array<string | Buffer>; image?: string | Buffer;
}> = ({ products, locale, logoBlack, productStartPage, galleryImages }) => { }> = ({ products, locale, logoBlack, productStartPage, image }) => {
const l = L(locale); const l = labels(locale);
const grouped = new Map<string, Array<{ product: BrochureProduct, pageNum: number }>>(); const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
let currentGlobalIdx = 0; let idx = 0;
for (const p of products) { for (const p of products) {
const cat = p.categories[0]?.name || 'Other'; const cat = p.categories[0]?.name || 'Other';
if (!grouped.has(cat)) grouped.set(cat, []); if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + currentGlobalIdx }); grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
currentGlobalIdx++; idx++;
} }
return ( return (
<Page size="A4" style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}> <Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<FixedHeader logoBlack={logoBlack} rightText={l.overview} /> <Header logo={logoBlack} right={l.overview} />
<Footer left="KLZ Cables" right="www.klz-cables.com" logoBlack={logoBlack} /> <PageFooter left="KLZ Cables" right="www.klz-cables.com" />
<View style={{ paddingHorizontal: M.h }}> {/* Image strip */}
<SectionHeading label={l.catalog} title={l.toc} isDark={false} /> {imgValid(image) && (
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
{/* Decorative image edge-to-edge */} <Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{galleryImages?.[5] && (
<View style={{ marginHorizontal: -M.h, height: 160, marginBottom: S.xxl }}>
<Image src={galleryImages[5]} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View> </View>
)} )}
<View style={{ flexDirection: 'column' }}> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.catalog}</Text>
<Text style={{ fontSize: 28, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>{l.toc}</Text>
<AccentBar />
{Array.from(grouped.entries()).map(([cat, items]) => ( {Array.from(grouped.entries()).map(([cat, items]) => (
<View key={cat} style={{ width: '100%', marginBottom: S.md }}> <View key={cat} style={{ marginBottom: 16 }}>
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.primaryDark, borderBottomStyle: 'solid', paddingBottom: S.xs, marginBottom: S.sm }}> <View style={{ borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 6 }}>
<Text style={{ ...T.label(false) }}>{cat}</Text> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2 }}>{cat}</Text>
</View> </View>
{items.map((item, i) => ( {items.map((item, i) => (
<View key={i} style={{ <View key={i} style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 5, paddingVertical: 5,
borderBottomWidth: i < items.length - 1 ? 0.5 : 0, borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
borderBottomColor: C.gray200, borderBottomStyle: 'solid', borderBottomColor: C.gray200, borderBottomStyle: 'solid',
}}> }}>
<Text style={{ fontSize: 11, fontWeight: 700, color: C.primaryDark }}>{item.product.name}</Text> <Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep }}>{item.product.name}</Text>
<Text style={{ fontSize: 10, color: C.gray400 }}>{l.page} {item.pageNum}</Text> <Text style={{ fontSize: 9, color: C.gray400 }}>{l.page} {item.pageNum}</Text>
</View> </View>
))} ))}
</View> </View>
))} ))}
</View>
</View>
</Page> </Page>
); );
}; };
// ─── Product Block ────────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════
// PRODUCT PAGES
// ═══════════════════════════════════════════════════════════════════════════
const ProductBlock: React.FC<{ const ProductPage: React.FC<{
product: BrochureProduct; product: BrochureProduct;
locale: 'en' | 'de'; locale: 'en' | 'de';
}> = ({ product, locale }) => { logoBlack?: string | Buffer;
const l = L(locale); }> = ({ product, locale, logoBlack }) => {
const desc = stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml); const l = labels(locale);
const LABEL_WIDTH = 260; // Wider label container for technical data const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
return ( return (
<View> <Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<SectionHeading <Header logo={logoBlack} right={l.overview} />
label={product.categories.map(c => c.name).join(' · ')} <PageFooter left="KLZ Cables" right="www.klz-cables.com" />
title={product.name}
isDark={false}
/>
{/* Edge-to-edge product image */} {/* Category + Name */}
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
{product.categories.map(c => c.name).join(' · ')}
</Text>
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.3, marginBottom: 8 }}>{product.name}</Text>
<AccentBar />
{/* Full-width product image */}
<View style={{ <View style={{
marginHorizontal: -M.h, height: 160, marginHorizontal: -MARGIN, marginBottom: 24,
height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: C.offWhite,
backgroundColor: C.gray050, justifyContent: 'center', alignItems: 'center',
borderTopWidth: 0.5, borderTopColor: C.gray200, borderTopStyle: 'solid', padding: 16,
borderBottomWidth: 0.5, borderBottomColor: C.gray200, borderBottomStyle: 'solid',
marginBottom: S.xl, padding: S.lg
}}> }}>
{product.featuredImage ? ( {product.featuredImage ? (
<Image src={product.featuredImage} style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> <Image src={product.featuredImage} style={{ maxWidth: '80%', maxHeight: '100%', objectFit: 'contain' }} />
) : ( ) : (
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text> <Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
)} )}
</View> </View>
{/* Description + QR */} {/* Description + QR in two columns */}
<View style={{ flexDirection: 'row', gap: S.xl, marginBottom: S.xl }}> <View style={{ flexDirection: 'row', gap: 32, marginBottom: 24 }}>
<View style={{ flex: 1.8 }}> <View style={{ flex: 2 }}>
{desc && ( {desc && (
<View> <View>
<Text style={{ ...T.label(false), marginBottom: S.md }}>{l.application}</Text> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.application}</Text>
<RichText style={{ ...T.body(false), lineHeight: 1.8 }}>{desc}</RichText> <RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }}>{desc}</RichText>
</View> </View>
)} )}
</View> </View>
<View style={{ flex: 1, flexDirection: 'column', justifyContent: 'flex-start' }}> <View style={{ flex: 1 }}>
{(product.qrWebsite || product.qrDatasheet) && ( {(product.qrWebsite || product.qrDatasheet) && (
<View style={{ flexDirection: 'column', gap: S.md }}> <View style={{ gap: 14 }}>
{product.qrWebsite && ( {product.qrWebsite && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: S.md }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ padding: 4, borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid' }}> <View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrWebsite} style={{ width: 44, height: 44 }} /> <Image src={product.qrWebsite} style={{ width: 40, height: 40 }} />
</View> </View>
<View> <View>
<Text style={{ ...T.caption(false), fontWeight: 700, color: C.primaryDark, marginBottom: 2 }}>{l.qrWeb}</Text> <Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrWeb}</Text>
<Text style={{ fontSize: 8, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text> <Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
</View> </View>
</View> </View>
)} )}
{product.qrDatasheet && ( {product.qrDatasheet && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: S.md }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ padding: 4, borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid' }}> <View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrDatasheet} style={{ width: 44, height: 44 }} /> <Image src={product.qrDatasheet} style={{ width: 40, height: 40 }} />
</View> </View>
<View> <View>
<Text style={{ ...T.caption(false), fontWeight: 700, color: C.primaryDark, marginBottom: 2 }}>{l.qrPdf}</Text> <Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrPdf}</Text>
<Text style={{ fontSize: 8, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text> <Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
</View> </View>
</View> </View>
)} )}
@@ -552,141 +493,141 @@ const ProductBlock: React.FC<{
</View> </View>
</View> </View>
{/* Technical Data Table — clean, minimal header, wider labels */} {/* Technical Data */}
{product.attributes && product.attributes.length > 0 && ( {product.attributes && product.attributes.length > 0 && (
<View> <View>
<Text style={{ ...T.label(false), marginBottom: S.sm }}>{l.specs}</Text> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
<View style={{ {/* Clean table header */}
flexDirection: 'row', <View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
borderBottomWidth: 1.5, borderBottomColor: C.primaryDark, borderBottomStyle: 'solid', <View style={{ width: '55%' }}>
paddingBottom: S.xs, marginBottom: 4, <Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
}}>
<View style={{ width: LABEL_WIDTH, paddingHorizontal: 10 }}>
<Text style={{ ...T.label(false), fontSize: 7, color: C.gray600 }}>
{locale === 'de' ? 'Eigenschaft' : 'Property'}
</Text>
</View> </View>
<View style={{ flex: 1, paddingHorizontal: 10 }}> <View style={{ flex: 1 }}>
<Text style={{ ...T.label(false), fontSize: 7, color: C.gray600 }}> <Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.value}</Text>
{locale === 'de' ? 'Wert' : 'Value'}
</Text>
</View> </View>
</View> </View>
{/* Rows */}
{product.attributes.map((attr, i) => ( {product.attributes.map((attr, i) => (
<View key={i} style={{ <View key={i} style={{
flexDirection: 'row', flexDirection: 'row',
paddingVertical: 5,
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0, borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
borderBottomColor: C.gray200, borderBottomStyle: 'solid', borderBottomColor: C.gray200, borderBottomStyle: 'solid',
backgroundColor: i % 2 === 0 ? C.white : C.gray050,
paddingVertical: 6,
}}> }}>
<View style={{ <View style={{ width: '55%', paddingRight: 8 }}>
width: LABEL_WIDTH, paddingHorizontal: 10, <Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
borderRightWidth: 1, borderRightColor: C.gray200, borderRightStyle: 'solid',
}}>
<Text style={{ fontSize: 9, fontWeight: 700, color: C.primaryDark, letterSpacing: 0.2 }}>{attr.name}</Text>
</View> </View>
<View style={{ flex: 1, paddingHorizontal: 10, justifyContent: 'center' }}> <View style={{ flex: 1 }}>
<Text style={{ fontSize: 10, color: C.gray900 }}>{attr.options.join(', ')}</Text> <Text style={{ fontSize: 9, color: C.gray900 }}>{attr.options.join(', ')}</Text>
</View> </View>
</View> </View>
))} ))}
</View> </View>
)} )}
</View>
);
};
// ─── Products Flow ──────────────────────────────────────────────────────────
const ProductsFlow: React.FC<{
products: BrochureProduct[];
locale: 'en' | 'de';
logoBlack?: string | Buffer;
}> = ({ products, locale, logoBlack }) => {
const l = L(locale);
return (
<React.Fragment>
{products.map((p) => (
<Page key={p.id} size="A4" style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}>
<FixedHeader logoBlack={logoBlack} rightText={l.overview} />
<Footer left="KLZ Cables" right="www.klz-cables.com" logoBlack={logoBlack} />
<View style={{ paddingHorizontal: M.h }}>
<ProductBlock product={p} locale={locale} />
</View>
</Page> </Page>
))}
</React.Fragment>
); );
}; };
// ─── Back Cover ───────────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════
// BACK COVER
// ═══════════════════════════════════════════════════════════════════════════
const BackCoverPage: React.FC<{ const BackCover: React.FC<{
companyInfo: BrochureProps['companyInfo']; companyInfo: BrochureProps['companyInfo'];
locale: 'en' | 'de'; locale: 'en' | 'de';
logoWhite?: string | Buffer; logoWhite?: string | Buffer;
galleryImages?: Array<string | Buffer>; image?: string | Buffer;
}> = ({ companyInfo, locale, logoWhite, galleryImages }) => { }> = ({ companyInfo, locale, logoWhite, image }) => {
const l = L(locale); const l = labels(locale);
const bgImage = galleryImages?.[6];
return ( return (
<Page size="A4" style={{ backgroundColor: C.primaryDark, fontFamily: 'Helvetica' }}> <Page size="A4" style={{ fontFamily: 'Helvetica' }}>
{bgImage && ( {/* Background */}
{imgValid(image) && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}> <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<Image src={bgImage} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.9 }} /> <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.92 }} />
</View> </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: M.h }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: MARGIN }}>
{logoWhite ? ( {imgValid(logoWhite) ? (
<Image src={logoWhite} style={{ width: 180, marginBottom: S.xl }} /> <Image src={logoWhite!} style={{ width: 160, marginBottom: 40 }} />
) : ( ) : (
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, letterSpacing: 2, textTransform: 'uppercase', marginBottom: S.xl }}>KLZ CABLES</Text> <Text style={{ fontSize: 28, fontWeight: 700, color: C.white, letterSpacing: 3, textTransform: 'uppercase', marginBottom: 40 }}>KLZ CABLES</Text>
)} )}
<View style={{ width: 48, height: 4, backgroundColor: C.accent, borderRadius: 2, marginBottom: S.xl }} /> <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 40 }} />
<View style={{ alignItems: 'center', marginBottom: S.lg }}> <Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.contact}</Text>
<Text style={{ ...T.label(true), marginBottom: S.sm }}>{l.contact}</Text> <Text style={{ fontSize: 12, color: C.white, lineHeight: 1.8, textAlign: 'center', marginBottom: 20 }}>{companyInfo.address}</Text>
<Text style={{ fontSize: 13, color: C.white, lineHeight: 1.7, textAlign: 'center' }}>{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>
<View style={{ alignItems: 'center', marginBottom: S.lg }}> <View style={{ position: 'absolute', bottom: 28, left: MARGIN, right: MARGIN, alignItems: 'center' }} fixed>
<Text style={{ fontSize: 13, color: C.white }}>{companyInfo.phone}</Text> <Text style={{ fontSize: 8, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
<Text style={{ fontSize: 13, color: C.gray300 }}>{companyInfo.email}</Text>
</View>
<Text style={{ fontSize: 14, fontWeight: 700, color: C.accent, marginTop: S.lg }}>{companyInfo.website}</Text>
</View>
<View style={{ position: 'absolute', bottom: 40, left: M.h, right: M.h, flexDirection: 'row', justifyContent: 'center' }} fixed>
<Text style={{ fontSize: 9, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
</View> </View>
</Page> </Page>
); );
}; };
// ─── Main Document ────────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════
// DOCUMENT
// ═══════════════════════════════════════════════════════════════════════════
export const PDFBrochure: React.FC<BrochureProps> = ({ export const PDFBrochure: React.FC<BrochureProps> = ({
products, locale, companyInfo, introContent, products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages, marketingSections, logoBlack, logoWhite, galleryImages,
}) => { }) => {
const productStartPage = 5; // Calculate actual page numbers
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections
const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC
// Assign images to sections: dark sections get indices 2,4; light get 3
const sectionThemes: Array<'light' | 'dark'> = [];
if (marketingSections) {
for (let i = 0; i < marketingSections.length; i++) {
// Alternate: light, dark, light, dark, light, dark
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
}
}
return ( return (
<Document> <Document>
<CoverPage locale={locale} introContent={introContent} logoBlack={logoBlack} logoWhite={logoWhite} galleryImages={galleryImages} /> <CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
<InfoFlow locale={locale} companyInfo={companyInfo} marketingSections={marketingSections} logoBlack={logoBlack} galleryImages={galleryImages} />
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} galleryImages={galleryImages} /> {/* About page with image index 1 */}
<ProductsFlow products={products} locale={locale} logoBlack={logoBlack} /> <AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
<BackCoverPage companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} galleryImages={galleryImages} />
{/* Each marketing section gets its own page */}
{marketingSections?.map((section, i) => (
<InfoPage
key={`info-${i}`}
section={section}
image={galleryImages?.[i + 2]}
logoBlack={logoBlack}
dark={sectionThemes[i] === 'dark'}
/>
))}
{/* TOC */}
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
{/* Products — each on its own page */}
{products.map(p => (
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
))}
{/* Back cover */}
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
</Document> </Document>
); );
}; };

BIN
lychee Executable file

Binary file not shown.

View File

@@ -104,47 +104,49 @@ function generateExcelForProduct(product: ProductData): Buffer {
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
const l = product.locale === 'de'; const l = product.locale === 'de';
// ── Sheet 1: Product Info ── // Single sheet: all cross-sections from all voltage tables combined
const infoData: Array<[string, string]> = [ const hasMultipleVoltages = product.voltageTables.length > 1;
[l ? 'Produktname' : 'Product Name', product.title], const allRows: string[][] = [];
[l ? 'Artikelnummer' : 'SKU', product.sku],
[l ? 'Kategorie' : 'Category', product.categories.join(', ') || '-'],
[l ? 'Beschreibung' : 'Description', product.description || '-'],
];
const infoSheet = XLSX.utils.aoa_to_sheet(infoData); // Build unified header row
infoSheet['!cols'] = [{ wch: 25 }, { wch: 65 }]; // Use columns from the first voltage table (they're the same across tables)
XLSX.utils.book_append_sheet(workbook, infoSheet, l ? 'Produktinfo' : 'Product Info'); const refTable = product.voltageTables[0];
if (!refTable) {
// ── Sheet 2: Technical Data ── // No voltage tables — create a minimal info sheet
if (product.technicalItems.length > 0) { const ws = XLSX.utils.aoa_to_sheet([
const techData: Array<[string, string]> = product.technicalItems.map(item => { [product.title],
const label = item.unit ? `${item.label} [${item.unit}]` : item.label; [l ? 'Keine Querschnittsdaten verfügbar' : 'No cross-section data available'],
return [label, item.value];
});
const techSheet = XLSX.utils.aoa_to_sheet([
[l ? 'Eigenschaft' : 'Property', l ? 'Wert' : 'Value'],
...techData
]); ]);
techSheet['!cols'] = [{ wch: 40 }, { wch: 60 }]; ws['!cols'] = [{ wch: 40 }];
XLSX.utils.book_append_sheet(workbook, techSheet, l ? 'Technische Daten' : 'Technical Data'); XLSX.utils.book_append_sheet(workbook, ws, product.title.substring(0, 31));
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
} }
// ── Sheet 3+: Voltage Tables ── const headers: string[] = [
l ? 'Querschnitt' : 'Cross-section',
...(hasMultipleVoltages ? [l ? 'Spannung' : 'Voltage'] : []),
...refTable.columns.map(c => c.label),
];
allRows.push(headers);
// Merge rows from all voltage tables
for (const table of product.voltageTables) { for (const table of product.voltageTables) {
const headers = ['Configuration/Cross-section', ...table.columns.map(c => c.label)]; for (let rowIdx = 0; rowIdx < table.crossSections.length; rowIdx++) {
const dataRows = table.crossSections.map((cs, rowIndex) => { const row: string[] = [
return [cs, ...table.columns.map(c => c.get(rowIndex) || '-')]; table.crossSections[rowIdx],
}); ...(hasMultipleVoltages ? [table.voltageLabel] : []),
...table.columns.map(c => c.get(rowIdx) || '-'),
const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows]); ];
ws['!cols'] = headers.map(() => ({ wch: 22 })); allRows.push(row);
const safeName = table.voltageLabel.replace(/[:\\/?*[\]]/g, '-').trim();
const sheetName = safeName.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
} }
}
const ws = XLSX.utils.aoa_to_sheet(allRows);
// Auto-width: first col wider for cross-section labels
ws['!cols'] = headers.map((_, i) => ({ wch: i === 0 ? 30 : 18 }));
const sheetName = product.title.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
return Buffer.from(buffer); return Buffer.from(buffer);

17
scripts/test-axios.ts Normal file
View File

@@ -0,0 +1,17 @@
import axios from 'axios';
async function test() {
const u = 'https://testing.klz-cables.com/de/blog/johannes-gleich-startet-als-senior-key-account-manager-durch';
try {
const res = await axios.get(u, {
headers: { Cookie: 'klz_gatekeeper_session=lassmichrein' },
validateStatus: (status) => status < 400
});
console.log('Status:', res.status);
console.log('Headers:', res.headers);
} catch (err: any) {
console.log('Error status:', err.response?.status);
console.log('Error data:', err.response?.data);
}
}
test();