feat: product catalog

This commit is contained in:
2026-03-02 16:20:54 +01:00
parent e9ceae3989
commit 501f9659a1
4 changed files with 112 additions and 115 deletions

View File

@@ -74,6 +74,7 @@ export interface BrochureProps {
}>; }>;
galleryImages?: Array<string | Buffer | undefined>; galleryImages?: Array<string | Buffer | undefined>;
messages?: Record<string, any>; messages?: Record<string, any>;
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
} }
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -341,8 +342,8 @@ const InfoPage: React.FC<{
<FadeImage <FadeImage
src={image!} src={image!}
bottom={FOOTER_H + 20} left={0} right={0} bottom={FOOTER_H + 20} left={0} right={0}
width="100%" height={220} width="100%" height={340}
fadeEdge="top" fadeSize={110} fadeEdge="top" fadeSize={140}
bgColor={bg} bgColor={bg}
opacity={dark ? 0.85 : 0.9} // Extremely subtle opacity={dark ? 0.85 : 0.9} // Extremely subtle
/> />
@@ -378,7 +379,7 @@ const InfoPage: React.FC<{
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid', borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
paddingVertical: 12, paddingHorizontal: 12, paddingVertical: 12, paddingHorizontal: 12,
}}> }}>
<Text style={{ fontSize: 16, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text> <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> <Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
</View> </View>
))} ))}
@@ -423,7 +424,8 @@ const AboutPage: React.FC<{
logoBlack?: string | Buffer; logoBlack?: string | Buffer;
image?: string | Buffer; image?: string | Buffer;
messages?: Record<string, any>; messages?: Record<string, any>;
}> = ({ locale, companyInfo, logoBlack, image, messages }) => { directorPhotos?: { michael?: Buffer; klaus?: Buffer };
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
const l = labels(locale); const l = labels(locale);
// Image at top: 200pt tall (smaller to leave more room for content) // Image at top: 200pt tall (smaller to leave more room for content)
@@ -463,18 +465,15 @@ const AboutPage: React.FC<{
{companyInfo.tagline} {companyInfo.tagline}
</RichText> </RichText>
{/* Legacy / Heritage section */} {/* Company mission — makes immediately clear what KLZ does */}
{legacy && ( <View style={{ marginTop: 12, marginBottom: 8 }}>
<View style={{ marginTop: 16, borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 14, paddingVertical: 4 }}> <RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
<Text style={{ fontSize: 11, fontWeight: 700, color: C.navyDeep, marginBottom: 4 }}>{legacy.title}</Text> {locale === 'de'
<RichText style={{ fontSize: 8.5, color: C.gray600, lineHeight: 1.6 }} gap={4}> ? '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.'
{legacy.p1} : '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> }
<RichText style={{ fontSize: 8.5, color: C.gray600, lineHeight: 1.6 }} gap={4}> </RichText>
{legacy.p2} </View>
</RichText>
</View>
)}
{/* Directors — two-column */} {/* Directors — two-column */}
{(michael || klaus) && ( {(michael || klaus) && (
@@ -483,15 +482,22 @@ const AboutPage: React.FC<{
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'} {locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
</Text> </Text>
<View style={{ flexDirection: 'row', gap: 20 }}> <View style={{ flexDirection: 'row', gap: 20 }}>
{[michael, klaus].filter(Boolean).map((person, i) => ( {[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
<View key={i} style={{ flex: 1 }}> <View key={i} style={{ flex: 1 }}>
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 2 }}>{person.name}</Text> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }}>{person.role}</Text> {p.photo && (
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{person.description}</Text> <Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
{person.quote && ( )}
<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 }}> <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 }}> <Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
„{person.quote}" „{p.data.quote}
</Text> </Text>
</View> </View>
)} )}
@@ -742,7 +748,7 @@ const BackCover: React.FC<{
export const PDFBrochure: React.FC<BrochureProps> = ({ export const PDFBrochure: React.FC<BrochureProps> = ({
products, locale, companyInfo, introContent, products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages, messages, marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
}) => { }) => {
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1) // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
const numInfoPages = 1 + (marketingSections?.length || 0); const numInfoPages = 1 + (marketingSections?.length || 0);
@@ -770,7 +776,7 @@ export const PDFBrochure: React.FC<BrochureProps> = ({
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} /> <CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
{/* About page — image[1] */} {/* About page — image[1] */}
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} /> <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 */} {/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */}
{marketingSections?.map((section, i) => ( {marketingSections?.map((section, i) => (

View File

@@ -254,91 +254,63 @@ async function loadMarketingSections(locale: 'en' | 'de'): Promise<BrochureProps
const sections: NonNullable<BrochureProps['marketingSections']> = []; const sections: NonNullable<BrochureProps['marketingSections']> = [];
// 1. What we do — short label, long subtitle becomes the description // ── 1. Was wir tun + Warum wir — MERGED into one compact section ──
if (messages.Home?.whatWeDo) { {
const label = locale === 'de' ? 'Unser Angebot' : 'Our Services'; const allItems: Array<{ title: string; description: string }> = [];
sections.push({
title: messages.Home.whatWeDo.title,
subtitle: label,
description: messages.Home.whatWeDo.subtitle,
items: messages.Home.whatWeDo.items,
});
}
// 2. Our Legacy — with stats highlight // WhatWeDo items — truncated to 1 sentence each
if (messages.Team?.legacy) { if (messages.Home?.whatWeDo?.items) {
const label = locale === 'de' ? 'Unsere Geschichte' : 'Our Story'; for (const item of messages.Home.whatWeDo.items) {
sections.push({ allItems.push({
title: messages.Team.legacy.title, title: item.title.split('.')[0], // short title
subtitle: label, description: item.description.split('.')[0] + '.',
description: `${messages.Team.legacy.p1}\n\n${messages.Team.legacy.p2}`, });
highlights: [ }
{ value: messages.Team.legacy.expertise || 'Expertise', label: messages.Team.legacy.expertiseDesc || '' },
{ value: messages.Team.legacy.network || 'Netzwerk', label: messages.Team.legacy.networkDesc || '' },
],
});
}
// 3. Experience stats section
if (messages.Home?.experience) {
const label = locale === 'de' ? 'Erfahrung' : 'Experience';
sections.push({
title: messages.Home.experience.title,
subtitle: label,
description: `${messages.Home.experience.p1 || ''}\n\n${messages.Home.experience.p2 || ''}`.trim(),
highlights: [
{ value: messages.Home.experience.certifiedQuality || (locale === 'de' ? 'Zertifizierte Qualität' : 'Certified Quality'), label: messages.Home.experience.vdeApproved || '' },
{ value: messages.Home.experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'), label: messages.Home.experience.solutionsRange || '' },
],
});
}
// 4. Why choose us
if (messages.Home?.whyChooseUs) {
const label = locale === 'de' ? 'Warum KLZ' : 'Why KLZ';
sections.push({
title: messages.Home.whyChooseUs.title,
subtitle: label,
description: messages.Home.whyChooseUs.subtitle || '',
items: messages.Home.whyChooseUs.items,
});
}
// 5. Team intro + quotes as pull quotes
if (messages.Team?.klaus || messages.Team?.michael) {
const label = locale === 'de' ? 'Die Geschäftsführer' : 'The Directors';
const title = messages.Home?.meetTheTeam?.title || (locale === 'de' ? 'Das Team' : 'The Team');
const teamItems: Array<{ title: string; description: string }> = [];
if (messages.Team?.klaus) {
teamItems.push({
title: `${messages.Team.klaus.name} ${messages.Team.klaus.role}`,
description: messages.Team.klaus.description,
});
} }
if (messages.Team?.michael) { // WhyChooseUs items — truncated to 1 sentence each
teamItems.push({ if (messages.Home?.whyChooseUs?.items) {
title: `${messages.Team.michael.name} ${messages.Team.michael.role}`, for (const item of messages.Home.whyChooseUs.items) {
description: messages.Team.michael.description, allItems.push({
}); title: item.title,
description: item.description.split('.')[0] + '.',
});
}
} }
const desc = messages.Home?.meetTheTeam?.description || '';
sections.push({ sections.push({
title, title: messages.Home?.whatWeDo?.title || (locale === 'de' ? 'Was wir tun' : 'What We Do'),
subtitle: label, subtitle: locale === 'de' ? 'Leistungen & Stärken' : 'Services & Strengths',
description: messages.Home?.whatWeDo?.subtitle || '',
items: allItems,
});
}
// ── 2. Experience & Quality — merge Legacy + Experience highlights ──
{
const legacy = messages.Team?.legacy;
const experience = messages.Home?.experience;
const highlights: Array<{ value: string; label: string }> = [];
if (legacy) {
highlights.push(
{ value: legacy.expertise || 'Expertise', label: legacy.expertiseDesc || '' },
{ value: legacy.network || (locale === 'de' ? 'Netzwerk' : 'Network'), label: legacy.networkDesc || '' },
);
}
if (experience) {
highlights.push(
{ value: experience.certifiedQuality || (locale === 'de' ? 'Zertifiziert' : 'Certified'), label: experience.vdeApproved || '' },
{ value: experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'), label: experience.solutionsRange || '' },
);
}
const desc = legacy?.p1 || '';
sections.push({
title: legacy?.title || (locale === 'de' ? 'Erfahrung & Qualität' : 'Experience & Quality'),
subtitle: locale === 'de' ? 'Unser Erbe' : 'Our Heritage',
description: desc, description: desc,
items: teamItems, highlights,
pullQuote: messages.Team.klaus?.quote || messages.Team.michael?.quote || '',
});
}
// 6. Our Values (Manifesto)
if (messages.Team?.manifesto) {
const label = locale === 'de' ? 'Grundprinzipien' : 'Core Principles';
sections.push({
title: messages.Team.manifesto.title,
subtitle: label,
description: messages.Team.manifesto.tagline,
items: messages.Team.manifesto.items,
}); });
} }
@@ -395,18 +367,14 @@ async function main(): Promise<void> {
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`); console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
// EXACT image mapping to website sections to prevent "random" images. // EXACT image mapping — 2 marketing sections now
// Index map: 0=Cover, 1=About, 2=WhatWeDo, 3=Legacy, 4=Experience, 5=WhyChooseUs, 6=Team, 7=Manifesto, 8=BackCover // Index map: 0=Cover, 1=About, 2=WasWirTun(null), 3=Erfahrung(Legacy image), 4=BackCover
const galleryPaths: Array<string | null> = [ const galleryPaths: Array<string | null> = [
'uploads/2024/12/DSC07655-Large.webp', // 0: Cover (Hero) 'uploads/2024/12/large-rolls-of-wires-against-the-blue-sky-at-sunse-2023-11-27-05-20-33-utc-Large.webp', // 0: Cover (cable drums, no people)
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section 'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
null, // 2: What we do (NO IMAGE) null, // 2: Was wir tun (NO IMAGE — text-heavy)
'uploads/2024/12/1694273920124-copy.webp', // 3: Legacy (Matching Team page) 'uploads/2024/12/1694273920124-copy.webp', // 3: Erfahrung & Qualität
'uploads/2024/12/1694273920124-copy-2.webp', // 4: Experience (Matching Home page) 'uploads/2024/12/DSC07433-Large-600x400.webp', // 4: Back cover
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 | undefined)[] = []; const galleryImages: (string | Buffer | undefined)[] = [];
@@ -447,12 +415,35 @@ async function main(): Promise<void> {
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8')); messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
} catch { /* messages are optional */ } } catch { /* messages are optional */ }
// Load director portrait photos and crop to circles
const directorPhotos: { michael?: Buffer; klaus?: Buffer } = {};
const portraitPaths = {
michael: path.join(process.cwd(), 'public/uploads/2024/12/DSC07768-Large.webp'),
klaus: path.join(process.cwd(), 'public/uploads/2024/12/DSC07963-Large.webp'),
};
const AVATAR_SIZE = 120; // px, will be rendered at 32pt in PDF
const circleMask = Buffer.from(
`<svg width="${AVATAR_SIZE}" height="${AVATAR_SIZE}"><circle cx="${AVATAR_SIZE / 2}" cy="${AVATAR_SIZE / 2}" r="${AVATAR_SIZE / 2}" fill="white"/></svg>`
);
for (const [key, photoPath] of Object.entries(portraitPaths)) {
if (fs.existsSync(photoPath)) {
try {
const cropped = await sharp(photoPath)
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'top' })
.composite([{ input: circleMask, blend: 'dest-in' }])
.png()
.toBuffer();
directorPhotos[key as 'michael' | 'klaus'] = cropped;
} catch { /* skip */ }
}
}
try { try {
// Render the React-PDF brochure // Render the React-PDF brochure
const buffer = await renderToBuffer( const buffer = await renderToBuffer(
React.createElement(PDFBrochure, { React.createElement(PDFBrochure, {
products, locale, companyInfo, introContent, products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages, messages, marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
} as any) as any } as any) as any
); );