feat: product catalog

This commit is contained in:
2026-03-02 15:41:26 +01:00
parent ec3f2cf8c9
commit e9ceae3989
5 changed files with 439 additions and 241 deletions

View File

@@ -72,7 +72,8 @@ export interface BrochureProps {
highlights?: Array<{ value: string; label: string }>;
pullQuote?: string;
}>;
galleryImages?: Array<string | Buffer>;
galleryImages?: Array<string | Buffer | undefined>;
messages?: Record<string, any>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -86,19 +87,39 @@ const imgValid = (src?: string | Buffer): boolean => {
};
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
catalog: 'Produktkatalog',
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
property: 'Eigenschaft', value: 'Wert',
catalog: 'Katalog',
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: 'Product Catalog',
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
application: 'Application', specs: 'Technical Data', contact: 'Contact',
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
property: 'Property', value: 'Value',
catalog: '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 ──────────────────────────────────────────────────────────────
@@ -163,6 +184,69 @@ const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({
// 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
// ═══════════════════════════════════════════════════════════════════════════
@@ -171,7 +255,7 @@ const CoverPage: React.FC<{
locale: 'en' | 'de';
introContent?: BrochureProps['introContent'];
logoWhite?: string | Buffer;
galleryImages?: Array<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' });
@@ -199,10 +283,10 @@ const CoverPage: React.FC<{
{/* 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: 56, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -1, lineHeight: 1 }}>
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
{l.catalog}
</Text>
<Text style={{ fontSize: 14, color: C.gray300, lineHeight: 1.8, marginTop: 20, maxWidth: 340 }}>
<Text style={{ fontSize: 12, color: C.gray300, lineHeight: 1.8, marginTop: 16, maxWidth: 340 }}>
{introContent?.excerpt || l.subtitle}
</Text>
</View>
@@ -224,131 +308,215 @@ const InfoPage: React.FC<{
section: NonNullable<BrochureProps['marketingSections']>[0];
image?: string | Buffer;
logoBlack?: string | Buffer;
logoWhite?: string | Buffer;
dark?: boolean;
}> = ({ section, image, logoBlack, dark }) => {
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, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
<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={220}
fadeEdge="top" fadeSize={110}
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} />
{/* 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>
)}
{/* Content — pushed below image when top-position */}
<View style={{ paddingTop: bodyTopWithImg }}>
{/* 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 />
{/* 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: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
{section.description}
</RichText>
</View>
)}
{/* 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 — 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={{
flex: 1,
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
paddingVertical: 14, paddingHorizontal: 14,
}}>
<Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
<Text style={{ fontSize: 8, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
</View>
))}
</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: 16, 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: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
{section.pullQuote}"
</Text>
</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 with accent bars */}
{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}>
<View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
<Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
<RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
{item.description}
</RichText>
</View>
))}
</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, special layout with values grid)
// About page (first info page)
const AboutPage: React.FC<{
locale: 'en' | 'de';
companyInfo: BrochureProps['companyInfo'];
logoBlack?: string | Buffer;
image?: string | Buffer;
}> = ({ locale, companyInfo, logoBlack, image }) => {
messages?: Record<string, any>;
}> = ({ locale, companyInfo, logoBlack, image, messages }) => {
const l = labels(locale);
// Image at top: 200pt tall (smaller to leave more room for content)
const IMG_TOP_H = 200;
const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
// Pull directors content from messages if available
const team = messages?.Team || {};
const michael = team.michael;
const klaus = team.klaus;
const legacy = team.legacy;
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<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" />
{/* Full-width image at top */}
{imgValid(image) && (
<View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View>
)}
{/* 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 />
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
<Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
<AccentBar />
<RichText style={{ fontSize: 10, color: C.gray900, lineHeight: 1.8 }} gap={8}>
{companyInfo.tagline}
</RichText>
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
{companyInfo.tagline}
</RichText>
{/* Legacy / Heritage section */}
{legacy && (
<View style={{ marginTop: 16, borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 14, paddingVertical: 4 }}>
<Text style={{ fontSize: 11, fontWeight: 700, color: C.navyDeep, marginBottom: 4 }}>{legacy.title}</Text>
<RichText style={{ fontSize: 8.5, color: C.gray600, lineHeight: 1.6 }} gap={4}>
{legacy.p1}
</RichText>
<RichText style={{ fontSize: 8.5, color: C.gray600, lineHeight: 1.6 }} gap={4}>
{legacy.p2}
</RichText>
</View>
)}
{/* 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) => (
<View key={i} style={{ width: '46%', marginBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
{/* 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 }}>
{[michael, klaus].filter(Boolean).map((person, i) => (
<View key={i} style={{ flex: 1 }}>
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 2 }}>{person.name}</Text>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }}>{person.role}</Text>
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{person.description}</Text>
{person.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 }}>
„{person.quote}"
</Text>
</View>
)}
</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>
)}
{/* 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>
@@ -364,53 +532,52 @@ const TocPage: React.FC<{
locale: 'en' | 'de';
logoBlack?: string | Buffer;
productStartPage: number;
image?: string | Buffer;
}> = ({ products, locale, logoBlack, productStartPage, image }) => {
}> = ({ products, locale, logoBlack, productStartPage }) => {
const l = labels(locale);
const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
let idx = 0;
// 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 cat = p.categories[0]?.name || 'Other';
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
idx++;
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, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<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" />
{/* Image strip */}
{imgValid(image) && (
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</View>
)}
<View style={{ paddingTop: BODY_TOP + 40 }}>
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
{l.catalog}
</Text>
<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]) => (
<View key={cat} style={{ marginBottom: 16 }}>
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 6 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2 }}>{cat}</Text>
</View>
{items.map((item, i) => (
<View key={i} style={{
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 5,
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
}}>
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep }}>{item.product.name}</Text>
<Text style={{ fontSize: 9, color: C.gray400 }}>{l.page} {item.pageNum}</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>
))}
</View>
</Page>
);
};
@@ -425,82 +592,45 @@ const ProductPage: React.FC<{
logoBlack?: string | Buffer;
}> = ({ product, locale, logoBlack }) => {
const l = labels(locale);
const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
return (
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
<Header logo={logoBlack} right={l.overview} />
<Header logo={logoBlack} right="KLZ Cables" />
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
{/* 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={{
height: 160, marginHorizontal: -MARGIN, marginBottom: 24,
backgroundColor: C.offWhite,
justifyContent: 'center', alignItems: 'center',
padding: 16,
}}>
{product.featuredImage ? (
<Image src={product.featuredImage} style={{ maxWidth: '80%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
)}
</View>
{/* Description + QR in two columns */}
<View style={{ flexDirection: 'row', gap: 32, marginBottom: 24 }}>
<View style={{ flex: 2 }}>
{desc && (
<View>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.application}</Text>
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }}>{desc}</RichText>
</View>
)}
{/* 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>
<View style={{ flex: 1 }}>
{(product.qrWebsite || product.qrDatasheet) && (
<View style={{ gap: 14 }}>
{product.qrWebsite && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrWebsite} style={{ width: 40, height: 40 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{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: 10 }}>
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
<Image src={product.qrDatasheet} style={{ width: 40, height: 40 }} />
</View>
<View>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrPdf}</Text>
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
</View>
</View>
)}
</View>
)}
</View>
</View>
)}
{/* Technical Data */}
{/* 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>
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
<View style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
{/* Clean table header */}
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
<View style={{ width: '55%' }}>
{/* 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 }}>
@@ -511,21 +641,49 @@ const ProductPage: React.FC<{
{product.attributes.map((attr, i) => (
<View key={i} style={{
flexDirection: 'row',
paddingVertical: 5,
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: '55%', paddingRight: 8 }}>
<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: 9, color: C.gray900 }}>{attr.options.join(', ')}</Text>
<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>
);
};
@@ -584,19 +742,26 @@ const BackCover: React.FC<{
export const PDFBrochure: React.FC<BrochureProps> = ({
products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages,
marketingSections, logoBlack, logoWhite, galleryImages, messages,
}) => {
// 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
const numInfoPages = 1 + (marketingSections?.length || 0);
const productStartPage = 1 + numInfoPages + 1;
// Assign images to sections: dark sections get indices 2,4; light get 3
// 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++) {
// Alternate: light, dark, light, dark, light, dark
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
}
}
@@ -604,30 +769,32 @@ export const PDFBrochure: React.FC<BrochureProps> = ({
<Document>
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
{/* About page with image index 1 */}
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
{/* About page image[1] */}
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} />
{/* Each marketing section gets its own page */}
{/* 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 */}
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
{/* 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 */}
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
{/* Back cover — last gallery image */}
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
</Document>
);
};

View File

@@ -395,29 +395,39 @@ async function main(): Promise<void> {
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
// Load gallery images — 7 diverse images for different sections
const galleryPaths = [
'uploads/2024/12/DSC07433-Large-600x400.webp', // 0: Cover
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
'uploads/2025/01/technicians-inspecting-wind-turbines-in-a-green-en-2024-12-09-01-25-20-utc-scaled.webp', // 2: After "Was wir tun"
'uploads/2025/01/power-grid-station-electrical-distribution-statio-2023-11-27-05-25-36-utc-scaled.webp', // 3: After Legacy
'uploads/2025/01/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled.webp', // 4: After Experience
'uploads/2024/12/DSC07539-Large-600x400.webp', // 5: TOC page
'uploads/2025/01/business-planning-hand-using-laptop-for-working-te-2024-11-01-21-25-44-utc-scaled.webp', // 6: Back cover
// EXACT image mapping to website sections to prevent "random" images.
// Index map: 0=Cover, 1=About, 2=WhatWeDo, 3=Legacy, 4=Experience, 5=WhyChooseUs, 6=Team, 7=Manifesto, 8=BackCover
const galleryPaths: Array<string | null> = [
'uploads/2024/12/DSC07655-Large.webp', // 0: Cover (Hero)
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
null, // 2: What we do (NO IMAGE)
'uploads/2024/12/1694273920124-copy.webp', // 3: Legacy (Matching Team page)
'uploads/2024/12/1694273920124-copy-2.webp', // 4: Experience (Matching Home page)
null, // 5: Why choose us (NO IMAGE)
'uploads/2024/12/DSC08036-Large.webp', // 6: Team (Matching Team page)
null, // 7: Manifesto (NO IMAGE)
'uploads/2024/12/DSC07433-Large-600x400.webp', // 8: Back cover
];
const galleryImages: (string | Buffer)[] = [];
const galleryImages: (string | Buffer | undefined)[] = [];
for (const gp of galleryPaths) {
if (!gp) {
galleryImages.push(undefined);
continue;
}
const fullPath = path.join(process.cwd(), 'public', gp);
if (fs.existsSync(fullPath)) {
try {
const buf = await sharp(fullPath).png({ quality: 80 }).resize(600).toBuffer();
const buf = await sharp(fullPath).png({ quality: 80 }).resize(800).toBuffer();
galleryImages.push(buf);
} catch { /* skip */ }
} catch {
galleryImages.push(undefined);
}
} else {
galleryImages.push(Buffer.alloc(0)); // placeholder to maintain index mapping
galleryImages.push(undefined);
}
}
console.log(`Gallery images loaded: ${galleryImages.filter(b => (b as Buffer).length > 0).length}`);
console.log(`Gallery images mapping complete. Succeeded bindings: ${galleryImages.filter(b => b !== undefined).length}`);
for (const locale of locales) {
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
@@ -430,12 +440,19 @@ async function main(): Promise<void> {
if (products.length === 0) continue;
const companyInfo = getCompanyInfo(locale);
// Load messages for About page content (directors, legacy, etc.)
let messages: Record<string, any> | undefined;
try {
const messagesPath = path.join(process.cwd(), `messages/${locale}.json`);
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
} catch { /* messages are optional */ }
try {
// Render the React-PDF brochure
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages,
marketingSections, logoBlack, logoWhite, galleryImages, messages,
} as any) as any
);

14
scripts/inspect-start2.ts Normal file
View File

@@ -0,0 +1,14 @@
import { getPayload } from 'payload';
import configPromise from '../payload.config';
async function main() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: 'start' } },
locale: 'de',
depth: 2,
});
console.log(JSON.stringify(result.docs[0], null, 2));
}
main().catch(console.error);