511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
#!/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);
|