Files
klz-cables.com/scripts/generate-brochure.ts
Marc Mintel 0cb96dfbac
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
feat: product catalog
2026-03-01 10:19:13 +01:00

460 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env ts-node
/**
* Brochure Generator
*
* Generates a complete product catalog PDF brochure combining all products
* with company information, using ONLY data from Payload CMS.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as React from 'react';
import sharp from 'sharp';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToBuffer } from '@react-pdf/renderer';
// pdf-lib removed: no longer bundling individual datasheets
import { PDFBrochure, type BrochureProduct, type BrochureProps } from '../lib/pdf-brochure';
import { getDatasheetPath } from '../lib/datasheets';
import { mapFileSlugToTranslated } from '../lib/slugs';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/brochure'),
host: process.env.NEXT_PUBLIC_SITE_URL || 'https://klz-cables.com',
} as const;
// ─── Helpers ────────────────────────────────────────────────────────────────
async function resolveImage(url: string): Promise<string | Buffer> {
if (!url) return '';
let localPath = '';
// If it's a Payload media URL like /api/media/file/filename.ext
if (url.startsWith('/api/media/file/')) {
const filename = url.replace('/api/media/file/', '');
localPath = path.join(process.cwd(), 'public/media', filename);
} else if (url.startsWith('/media/')) {
localPath = path.join(process.cwd(), 'public', url);
}
if (localPath && fs.existsSync(localPath)) {
// If it's webp, convert to png buffer for react-pdf
if (localPath.toLowerCase().endsWith('.webp')) {
try {
return await sharp(localPath).png().toBuffer();
} catch (err) {
return localPath;
}
}
return localPath;
}
// Fallback to absolute URL if starting with /
if (url.startsWith('/')) return `${CONFIG.host}${url}`;
return url;
}
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
async function fetchQrCodeBuffer(url: string): Promise<Buffer | undefined> {
if (!url) return undefined;
try {
const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${encodeURIComponent(url)}&margin=0`;
const res = await fetch(qrApi);
if (res.ok) {
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
} else {
console.error(` [QR] Failed (HTTP ${res.status}) for ${url}`);
}
} catch (err) {
console.error(` [QR] Failed for ${url}:`, err);
}
return undefined;
}
async function resolveLocalFile(relativePath: string): Promise<string | Buffer | undefined> {
const abs = path.join(process.cwd(), 'public', relativePath);
if (!fs.existsSync(abs)) return undefined;
if (abs.endsWith('.svg')) {
try {
const svgBuf = fs.readFileSync(abs);
return await sharp(svgBuf).resize(600).png().toBuffer();
} catch { return abs; }
}
return abs;
}
// ─── CMS Product Loading ────────────────────────────────────────────────────
async function loadProducts(locale: 'en' | 'de'): Promise<BrochureProduct[]> {
const products: BrochureProduct[] = [];
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
pagination: false,
});
const productsSlug = await mapFileSlugToTranslated('products', locale);
let id = 1;
for (const doc of result.docs) {
if (!doc.title || !doc.slug) continue;
const images: any[] = [];
const rawImages: string[] = [];
if (doc.featuredImage) {
const url = typeof doc.featuredImage === 'string' ? doc.featuredImage : (doc.featuredImage as any).url;
if (url) rawImages.push(url);
}
if (Array.isArray(doc.images)) {
for (const img of doc.images) {
const url = typeof img === 'string' ? img : (img as any).url;
if (url && !rawImages.includes(url)) rawImages.push(url);
}
}
for (const url of rawImages) {
const resolved = await resolveImage(url);
if (resolved) images.push(resolved);
}
const attributes: any[] = [];
// Extract basic technical attributes from Lexical AST
if (Array.isArray(doc.content?.root?.children)) {
const productTabsBlock = doc.content.root.children.find(
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs'
);
if (productTabsBlock && productTabsBlock.fields) {
if (Array.isArray(productTabsBlock.fields.technicalItems)) {
for (const item of productTabsBlock.fields.technicalItems) {
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
if (label && item.value) {
attributes.push({ name: label, options: [item.value] });
}
}
}
if (Array.isArray(productTabsBlock.fields.voltageTables)) {
for (const vt of productTabsBlock.fields.voltageTables) {
if (vt.voltageLabel) {
attributes.push({ name: 'Voltage', options: [vt.voltageLabel] });
}
}
}
}
}
const categories = Array.isArray(doc.categories)
? doc.categories.map((c: any) => ({ name: String(c.category || c), slug: String(c.slug || c) })).filter((c: any) => c.name)
: [];
// Compute QR URLs
let qrWebsiteUrl = '';
if (categories.length > 0 && categories[0].slug) {
const catTranslatedSlug = await mapFileSlugToTranslated(categories[0].slug, locale);
qrWebsiteUrl = `${CONFIG.host}/${locale}/${productsSlug}/${catTranslatedSlug}/${doc.slug}`;
}
let qrDatasheetUrl = '';
const datasheetRelativePath = getDatasheetPath(String(doc.slug), locale);
if (datasheetRelativePath) {
qrDatasheetUrl = `${CONFIG.host}${datasheetRelativePath}`;
}
const [qrWebsite, qrDatasheet] = await Promise.all([
qrWebsiteUrl ? fetchQrCodeBuffer(qrWebsiteUrl) : Promise.resolve(undefined),
qrDatasheetUrl ? fetchQrCodeBuffer(qrDatasheetUrl) : Promise.resolve(undefined),
]);
products.push({
id: id++,
name: String(doc.title),
slug: String(doc.slug),
sku: String(doc.sku || ''),
shortDescriptionHtml: '',
descriptionHtml: stripHtml(String(doc.description || '')),
images: images as any, // mix of paths and buffers
featuredImage: images[0] || null,
categories,
attributes,
qrWebsite,
qrDatasheet,
});
console.log(` - ${doc.title} (QR: ${qrWebsite ? 'Web ' : ''}${qrDatasheet ? 'PDF' : ''})`);
}
} catch (error) {
console.error(`[Payload] Failed to fetch products (${locale}):`, error);
}
products.sort((a, b) => a.name.localeCompare(b.name));
// FILTER: Only include products that have images for the high-fidelity brochure
const filteredProducts = products.filter(p => p.images.length > 0 || p.featuredImage);
console.log(` Filtered: ${filteredProducts.length} products with images (out of ${products.length})`);
return filteredProducts;
}
// ─── CMS Start/Intro Page ───────────────────────────────────────────────────
async function loadIntroContent(locale: 'en' | 'de'): Promise<BrochureProps['introContent'] | undefined> {
try {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: 'start' } },
locale: locale as any,
});
if (result.docs.length > 0) {
const doc = result.docs[0];
const heroUrl = typeof doc.featuredImage === 'string'
? doc.featuredImage
: (doc.featuredImage as any)?.url;
const heroImage = await resolveImage(heroUrl);
return {
title: String(doc.title),
excerpt: String(doc.excerpt || ''),
heroImage: heroImage as any,
};
}
} catch (error) {
console.error(`[Payload] Failed to fetch intro content (${locale}):`, error);
}
return undefined;
}
// ─── Marketing Sections ───────────────────────────────────────────────────
async function loadMarketingSections(locale: 'en' | 'de'): Promise<BrochureProps['marketingSections'] | undefined> {
try {
const messagesPath = path.join(process.cwd(), `messages/${locale}.json`);
const messagesJson = fs.readFileSync(messagesPath, 'utf-8');
const messages = JSON.parse(messagesJson);
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,
});
}
// 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,
});
}
if (messages.Team?.michael) {
teamItems.push({
title: `${messages.Team.michael.name} ${messages.Team.michael.role}`,
description: messages.Team.michael.description,
});
}
const desc = messages.Home?.meetTheTeam?.description || '';
sections.push({
title,
subtitle: label,
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,
});
}
return sections.length > 0 ? sections : undefined;
} catch (error) {
console.error(`[Messages] Failed to fetch marketing sections (${locale}):`, error);
}
return undefined;
}
// ─── Company Info ───────────────────────────────────────────────────────────
function getCompanyInfo(locale: 'en' | 'de'): BrochureProps['companyInfo'] {
const values = locale === 'de' ? [
{ title: 'Kompetenz', description: 'Jahrzehntelange Erfahrung und europaweites Know-how.' },
{ title: 'Verfügbarkeit', description: 'Immer für Sie da schnelle Unterstützung.' },
{ title: 'Lösungen', description: 'Wir finden die beste Kabellösung für Ihr Projekt.' },
{ title: 'Zuverlässigkeit', description: 'Wir halten, was wir versprechen.' },
] : [
{ title: 'Competence', description: 'Decades of experience and Europe-wide know-how.' },
{ title: 'Availability', description: 'Always there for you fast support.' },
{ title: 'Solutions', description: 'We find the best cable solution for your project.' },
{ title: 'Reliability', description: 'We deliver what we promise.' },
];
return {
tagline: locale === 'de'
? 'Wegweisend in der Kabelinfrastruktur.'
: 'Leading the way in cable infrastructure.',
values,
address: 'Raiffeisenstraße 22, 73630 Remshalden, Germany',
phone: '+49 (0) 7151 959 89-0',
email: 'info@klz-cables.com',
website: 'www.klz-cables.com',
};
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const start = Date.now();
console.log('Starting brochure generation (Full Brochure with website content)');
ensureOutputDir();
const locales: Array<'en' | 'de'> = ['en', 'de'];
// Load the REAL logos (not the favicon/icon!)
const logoWhitePath = path.join(process.cwd(), 'public/logo-white.png');
const logoBlackPath = path.join(process.cwd(), 'public/logo-black.png');
const logoFallbackPath = path.join(process.cwd(), 'public/logo.png');
const logoWhite = fs.existsSync(logoWhitePath) ? logoWhitePath : undefined;
const logoBlack = fs.existsSync(logoBlackPath) ? logoBlackPath : (fs.existsSync(logoFallbackPath) ? logoFallbackPath : undefined);
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
];
const galleryImages: (string | Buffer)[] = [];
for (const gp of galleryPaths) {
const fullPath = path.join(process.cwd(), 'public', gp);
if (fs.existsSync(fullPath)) {
try {
const buf = await sharp(fullPath).png({ quality: 80 }).resize(600).toBuffer();
galleryImages.push(buf);
} catch { /* skip */ }
} else {
galleryImages.push(Buffer.alloc(0)); // placeholder to maintain index mapping
}
}
console.log(`Gallery images loaded: ${galleryImages.filter(b => (b as Buffer).length > 0).length}`);
for (const locale of locales) {
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
const [products, introContent, marketingSections] = await Promise.all([
loadProducts(locale),
loadIntroContent(locale),
loadMarketingSections(locale)
]);
if (products.length === 0) continue;
const companyInfo = getCompanyInfo(locale);
try {
// Render the React-PDF brochure
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products, locale, companyInfo, introContent,
marketingSections, logoBlack, logoWhite, galleryImages,
} as any) as any
);
// Write final PDF
const outPath = path.join(process.cwd(), `public/brochure/klz-product-catalog-${locale}.pdf`);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buffer);
const sizeKB = Math.round(buffer.length / 1024);
console.log(` ✓ Generated: klz-product-catalog-${locale}.pdf (${sizeKB} KB)`);
} catch (error) {
console.error(` ✗ Failed to generate ${locale} brochure:`, error);
}
}
console.log(`\n✅ Done!`);
console.log(`Output: ${CONFIG.outputDir}`);
console.log(`Time: ${((Date.now() - start) / 1000).toFixed(2)}s`);
}
main().catch(console.error);