Files
klz-cables.com/scripts/generate-brochure.ts

511 lines
18 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 present
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: [String(item.value)],
});
}
}
}
}
}
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. Was wir tun + Warum wir — MERGED into one compact section ──
{
const allItems: Array<{ title: string; description: string }> = [];
// 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] + '.',
});
}
}
// 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] + '.',
});
}
}
sections.push({
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,
highlights,
});
}
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 — 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/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: 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)[] = [];
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 */
}
// 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,
directorPhotos,
} 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);