Files
klz-cables.com/scripts/generate-brochure.ts
2026-03-02 15:41:26 +01:00

477 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}`);
// 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 | 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(800).toBuffer();
galleryImages.push(buf);
} catch {
galleryImages.push(undefined);
}
} else {
galleryImages.push(undefined);
}
}
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...`);
const [products, introContent, marketingSections] = await Promise.all([
loadProducts(locale),
loadIntroContent(locale),
loadMarketingSections(locale)
]);
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, messages,
} 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);