#!/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 { 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 { 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 { 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 { 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 { 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 { 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 = []; // ── 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 { 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 = [ '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 | 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( ``, ); 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);