feat: product catalog
This commit is contained in:
@@ -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) => (
|
||||||
|
|||||||
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']> = [];
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user