feat: product catalog
This commit is contained in:
@@ -74,6 +74,7 @@ export interface BrochureProps {
|
||||
}>;
|
||||
galleryImages?: Array<string | Buffer | undefined>;
|
||||
messages?: Record<string, any>;
|
||||
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -341,8 +342,8 @@ const InfoPage: React.FC<{
|
||||
<FadeImage
|
||||
src={image!}
|
||||
bottom={FOOTER_H + 20} left={0} right={0}
|
||||
width="100%" height={220}
|
||||
fadeEdge="top" fadeSize={110}
|
||||
width="100%" height={340}
|
||||
fadeEdge="top" fadeSize={140}
|
||||
bgColor={bg}
|
||||
opacity={dark ? 0.85 : 0.9} // Extremely subtle
|
||||
/>
|
||||
@@ -378,7 +379,7 @@ const InfoPage: React.FC<{
|
||||
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: 10, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -423,7 +424,8 @@ const AboutPage: React.FC<{
|
||||
logoBlack?: string | Buffer;
|
||||
image?: string | Buffer;
|
||||
messages?: Record<string, any>;
|
||||
}> = ({ locale, companyInfo, logoBlack, image, messages }) => {
|
||||
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
// Image at top: 200pt tall (smaller to leave more room for content)
|
||||
@@ -463,18 +465,15 @@ const AboutPage: React.FC<{
|
||||
{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>
|
||||
)}
|
||||
{/* Company mission — makes immediately clear what KLZ does */}
|
||||
<View style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
|
||||
{locale === 'de'
|
||||
? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.'
|
||||
: 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'
|
||||
}
|
||||
</RichText>
|
||||
</View>
|
||||
|
||||
{/* Directors — two-column */}
|
||||
{(michael || klaus) && (
|
||||
@@ -483,15 +482,22 @@ const AboutPage: React.FC<{
|
||||
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
|
||||
</Text>
|
||||
<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 }}>
|
||||
<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={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
{p.photo && (
|
||||
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||
)}
|
||||
<View>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{p.data.description}</Text>
|
||||
{p.data.quote && (
|
||||
<View style={{ borderLeftWidth: 2, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 8 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||
„{person.quote}"
|
||||
„{p.data.quote}“
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -742,7 +748,7 @@ const BackCover: React.FC<{
|
||||
|
||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||
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)
|
||||
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} />
|
||||
|
||||
{/* 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 */}
|
||||
{marketingSections?.map((section, i) => (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -254,91 +254,63 @@ async function loadMarketingSections(locale: 'en' | 'de'): Promise<BrochureProps
|
||||
|
||||
const sections: NonNullable<BrochureProps['marketingSections']> = [];
|
||||
|
||||
// 1. What we do — short label, long subtitle becomes the description
|
||||
if (messages.Home?.whatWeDo) {
|
||||
const label = locale === 'de' ? 'Unser Angebot' : 'Our Services';
|
||||
sections.push({
|
||||
title: messages.Home.whatWeDo.title,
|
||||
subtitle: label,
|
||||
description: messages.Home.whatWeDo.subtitle,
|
||||
items: messages.Home.whatWeDo.items,
|
||||
});
|
||||
}
|
||||
// ── 1. Was wir tun + Warum wir — MERGED into one compact section ──
|
||||
{
|
||||
const allItems: Array<{ title: string; description: string }> = [];
|
||||
|
||||
// 2. Our Legacy — with stats highlight
|
||||
if (messages.Team?.legacy) {
|
||||
const label = locale === 'de' ? 'Unsere Geschichte' : 'Our Story';
|
||||
sections.push({
|
||||
title: messages.Team.legacy.title,
|
||||
subtitle: label,
|
||||
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,
|
||||
});
|
||||
// WhatWeDo items — truncated to 1 sentence each
|
||||
if (messages.Home?.whatWeDo?.items) {
|
||||
for (const item of messages.Home.whatWeDo.items) {
|
||||
allItems.push({
|
||||
title: item.title.split('.')[0], // short title
|
||||
description: item.description.split('.')[0] + '.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (messages.Team?.michael) {
|
||||
teamItems.push({
|
||||
title: `${messages.Team.michael.name} – ${messages.Team.michael.role}`,
|
||||
description: messages.Team.michael.description,
|
||||
});
|
||||
// WhyChooseUs items — truncated to 1 sentence each
|
||||
if (messages.Home?.whyChooseUs?.items) {
|
||||
for (const item of messages.Home.whyChooseUs.items) {
|
||||
allItems.push({
|
||||
title: item.title,
|
||||
description: item.description.split('.')[0] + '.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const desc = messages.Home?.meetTheTeam?.description || '';
|
||||
|
||||
sections.push({
|
||||
title,
|
||||
subtitle: label,
|
||||
title: messages.Home?.whatWeDo?.title || (locale === 'de' ? 'Was wir tun' : 'What We Do'),
|
||||
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,
|
||||
items: teamItems,
|
||||
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,
|
||||
highlights,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -395,18 +367,14 @@ async function main(): Promise<void> {
|
||||
|
||||
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
|
||||
|
||||
// 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
|
||||
// EXACT image mapping — 2 marketing sections now
|
||||
// Index map: 0=Cover, 1=About, 2=WasWirTun(null), 3=Erfahrung(Legacy image), 4=BackCover
|
||||
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
|
||||
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
|
||||
null, // 2: Was wir tun (NO IMAGE — text-heavy)
|
||||
'uploads/2024/12/1694273920124-copy.webp', // 3: Erfahrung & Qualität
|
||||
'uploads/2024/12/DSC07433-Large-600x400.webp', // 4: Back cover
|
||||
];
|
||||
|
||||
const galleryImages: (string | Buffer | undefined)[] = [];
|
||||
@@ -447,12 +415,35 @@ async function main(): Promise<void> {
|
||||
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
|
||||
} 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 {
|
||||
// Render the React-PDF brochure
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages, messages,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||
} as any) as any
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user