diff --git a/lib/pdf-brochure.tsx b/lib/pdf-brochure.tsx index e71f99ba..175d4540 100644 --- a/lib/pdf-brochure.tsx +++ b/lib/pdf-brochure.tsx @@ -72,7 +72,8 @@ export interface BrochureProps { highlights?: Array<{ value: string; label: string }>; pullQuote?: string; }>; - galleryImages?: Array; + galleryImages?: Array; + messages?: Record; } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -86,19 +87,39 @@ const imgValid = (src?: string | Buffer): boolean => { }; const labels = (locale: 'en' | 'de') => locale === 'de' ? { - catalog: 'Produktkatalog', - subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel', - about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht', - application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt', - qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.', - property: 'Eigenschaft', value: 'Wert', + catalog: 'Katalog', + subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.', + about: 'Über uns', + toc: 'Inhalt', + overview: 'Übersicht', + application: 'Anwendungsbereich', + specs: 'Technische Daten', + contact: 'Kontakt', + qrWeb: 'Details', + qrPdf: 'PDF', + values: 'Unsere Werte', + edition: 'Ausgabe', + page: 'Seite', + property: 'Eigenschaft', + value: 'Wert', + other: 'Sonstige' } : { - catalog: 'Product Catalog', - subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables', - about: 'About Us', toc: 'Product Overview', overview: 'Product Overview', - application: 'Application', specs: 'Technical Data', contact: 'Contact', - qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.', - property: 'Property', value: 'Value', + catalog: 'Catalog', + subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.', + about: 'About Us', + toc: 'Contents', + overview: 'Overview', + application: 'Application', + specs: 'Technical Data', + contact: 'Contact', + qrWeb: 'Details', + qrPdf: 'PDF', + values: 'Our Values', + edition: 'Edition', + page: 'Page', + property: 'Property', + value: 'Value', + other: 'Other' }; // ─── Rich Text ────────────────────────────────────────────────────────────── @@ -163,6 +184,69 @@ const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ // Green accent bar const AccentBar = () => ; +// ─── FadeImage ───────────────────────────────────────────────────────────── +// Simulates a gradient fade at one edge using stacked opacity bands. +// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles. +// +// 'position' param: which edge fades INTO the page background +// 'bottom' → image visible at top, fades down into bgColor +// 'top' → image visible at bottom, fades up into bgColor +// 'right' → image on left side, fades right into bgColor +// +// The component must be placed ABSOLUTE (position: 'absolute') on the page. + +const FadeImage: React.FC<{ + src: string | Buffer; + top?: number; left?: number; right?: number; bottom?: number; + width: number | string; + height: number; + fadeEdge: 'bottom' | 'top' | 'right' | 'left'; + fadeSize?: number; // how many points the fade spans + bgColor: string; + opacity?: number; // overall image darkness (0–1, applied via overlay) +}> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => { + const STEPS = 40; // High number of overlapping bands + + const bands = Array.from({ length: STEPS }, (_, i) => { + // i=0 is the widest band reaching deepest into the image. + // i=STEPS-1 is the narrowest band right at the fade edge. + // Because they all anchor at the edge and overlap, their opacity compounds. + // We use an ease-in curve for distance to make the fade look natural. + const t = 1.0 / STEPS; + const easeDist = Math.pow((i + 1) / STEPS, 1.2); + const dist = fadeSize * easeDist; + + const style: any = { + position: 'absolute', + backgroundColor: bgColor, + opacity: t, + }; + + // All bands anchor at the fade edge and extend inward by `dist` + if (fadeEdge === 'bottom') { + Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 }); + } else if (fadeEdge === 'top') { + Object.assign(style, { left: 0, right: 0, height: dist, top: 0 }); + } else if (fadeEdge === 'right') { + Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 }); + } else { + Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 }); + } + + return style; + }); + + return ( + + + {/* Overlay using bgColor to "wash out" / dilute the image */} + {opacity > 0 && } + {/* Gradient fade bands */} + {bands.map((s, i) => )} + + ); +}; + // ═══════════════════════════════════════════════════════════════════════════ // PAGE 1: COVER // ═══════════════════════════════════════════════════════════════════════════ @@ -171,7 +255,7 @@ const CoverPage: React.FC<{ locale: 'en' | 'de'; introContent?: BrochureProps['introContent']; logoWhite?: string | Buffer; - galleryImages?: Array; + galleryImages?: Array; }> = ({ locale, introContent, logoWhite, galleryImages }) => { const l = labels(locale); const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' }); @@ -199,10 +283,10 @@ const CoverPage: React.FC<{ {/* Main title block — bottom third of page */} - + {l.catalog} - + {introContent?.excerpt || l.subtitle} @@ -224,131 +308,215 @@ const InfoPage: React.FC<{ section: NonNullable[0]; image?: string | Buffer; logoBlack?: string | Buffer; + logoWhite?: string | Buffer; dark?: boolean; -}> = ({ section, image, logoBlack, dark }) => { + imagePosition?: 'top' | 'bottom-half'; +}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => { const bg = dark ? C.navyDeep : C.white; const textColor = dark ? C.gray300 : C.gray600; const titleColor = dark ? C.white : C.navyDeep; const boldColor = dark ? C.white : C.navyDeep; + const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack; + + // Image at top: 240pt tall, content starts below via paddingTop + const IMG_TOP_H = 240; + const bodyTopWithImg = imagePosition === 'top' && imgValid(image) + ? IMG_TOP_H + 24 // content starts below image + : BODY_TOP; return ( - -
+ + {/* Absolute image — from page edge, fades into bg */} + {imgValid(image) && imagePosition === 'top' && ( + + )} + {imgValid(image) && imagePosition === 'bottom-half' && ( + + )} + + {/* Header — on top of image */} +
- {/* Full-width image at top */} - {imgValid(image) && ( - - - {dark && } - - )} + {/* Content — pushed below image when top-position */} + - {/* Label + Title */} - {section.subtitle} - {section.title} - + {/* Label + Title */} + {section.subtitle} + {section.title} - {/* Description */} - {section.description && ( - - - {section.description} - - - )} + {/* Description */} + {section.description && ( + + + {section.description} + + + )} - {/* Highlights — horizontal stat cards */} - {section.highlights && section.highlights.length > 0 && ( - - {section.highlights.map((h, i) => ( - - {h.value} - {h.label} - - ))} - - )} + {/* Highlights */} + {section.highlights && section.highlights.length > 0 && ( + + {section.highlights.map((h, i) => ( + + {h.value} + {h.label} + + ))} + + )} - {/* Pull quote */} - {section.pullQuote && ( - - - „{section.pullQuote}" - - - )} + {/* Pull quote */} + {section.pullQuote && ( + + + „{section.pullQuote}" + + + )} - {/* Items — 2-column grid with accent bars */} - {section.items && section.items.length > 0 && ( - - {section.items.map((item, i) => ( - - - {item.title} - - {item.description} - - - ))} - - )} + {/* Items — 2-column grid */} + {section.items && section.items.length > 0 && ( + + {section.items.map((item, i) => ( + + {item.title} + + + {item.description} + + + ))} + + )} + ); }; -// About page (first info page, special layout with values grid) +// About page (first info page) const AboutPage: React.FC<{ locale: 'en' | 'de'; companyInfo: BrochureProps['companyInfo']; logoBlack?: string | Buffer; image?: string | Buffer; -}> = ({ locale, companyInfo, logoBlack, image }) => { + messages?: Record; +}> = ({ locale, companyInfo, logoBlack, image, messages }) => { const l = labels(locale); + // Image at top: 200pt tall (smaller to leave more room for content) + const IMG_TOP_H = 200; + const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP; + + // Pull directors content from messages if available + const team = messages?.Team || {}; + const michael = team.michael; + const klaus = team.klaus; + const legacy = team.legacy; + return ( - + + {/* Top-aligned image fading into white bottom */} + {imgValid(image) && ( + + )} +
- {/* Full-width image at top */} - {imgValid(image) && ( - - - - )} + {/* Content pushed below the fading image */} + + {l.about} + KLZ Cables + - {l.about} - KLZ Cables - + + {companyInfo.tagline} + - - {companyInfo.tagline} - + {/* Legacy / Heritage section */} + {legacy && ( + + {legacy.title} + + {legacy.p1} + + + {legacy.p2} + + + )} - {/* Values grid */} - - {l.values} - - {companyInfo.values.map((v, i) => ( - - - - 0{i + 1} + {/* Directors — two-column */} + {(michael || klaus) && ( + + + {locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'} + + + {[michael, klaus].filter(Boolean).map((person, i) => ( + + {person.name} + {person.role} + {person.description} + {person.quote && ( + + + „{person.quote}" + + + )} - {v.title} - - {v.description} + ))} - ))} + + )} + + {/* Values grid */} + + {l.values} + + {companyInfo.values.map((v, i) => ( + + + + 0{i + 1} + + {v.title} + + {v.description} + + ))} + @@ -364,53 +532,52 @@ const TocPage: React.FC<{ locale: 'en' | 'de'; logoBlack?: string | Buffer; productStartPage: number; - image?: string | Buffer; -}> = ({ products, locale, logoBlack, productStartPage, image }) => { +}> = ({ products, locale, logoBlack, productStartPage }) => { const l = labels(locale); - const grouped = new Map>(); - let idx = 0; + // Group products by their first category + const categories: Array<{ name: string; products: Array }> = []; + let currentPageNum = productStartPage; for (const p of products) { - const cat = p.categories[0]?.name || 'Other'; - if (!grouped.has(cat)) grouped.set(cat, []); - grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx }); - idx++; + const catName = p.categories[0]?.name || l.other; + let category = categories.find(c => c.name === catName); + if (!category) { + category = { name: catName, products: [] }; + categories.push(category); + } + category.products.push({ ...p, startingPage: currentPageNum }); + currentPageNum++; } return ( - +
- {/* Image strip */} - {imgValid(image) && ( - - - - )} + + + {l.catalog} + - {l.catalog} - {l.toc} - - - {Array.from(grouped.entries()).map(([cat, items]) => ( - - - {cat} - - {items.map((item, i) => ( - - {item.product.name} - {l.page} {item.pageNum} + {categories.map((cat, i) => ( + + {cat.name && ( + + {cat.name} + + )} + + {cat.products.map((p, j) => ( + + {p.name} + + {(p.startingPage || 0).toString().padStart(2, '0')} + + ))} - ))} - - ))} + + ))} + ); }; @@ -425,82 +592,45 @@ const ProductPage: React.FC<{ logoBlack?: string | Buffer; }> = ({ product, locale, logoBlack }) => { const l = labels(locale); - const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml); return ( -
+
- {/* Category + Name */} - - {product.categories.map(c => c.name).join(' · ')} - - {product.name} - - - {/* Full-width product image */} - - {product.featuredImage ? ( - - ) : ( - - )} - - - {/* Description + QR in two columns */} - - - {desc && ( - - {l.application} - {desc} - - )} + {/* Product image block reduced strictly to 110pt high */} + {product.featuredImage && ( + + - - {(product.qrWebsite || product.qrDatasheet) && ( - - {product.qrWebsite && ( - - - - - - {l.qrWeb} - {locale === 'de' ? 'Produktseite' : 'Product Page'} - - - )} - {product.qrDatasheet && ( - - - - - - {l.qrPdf} - {locale === 'de' ? 'Datenblatt' : 'Datasheet'} - - - )} - - )} - - + )} - {/* Technical Data */} + {/* Labels & Name */} + {product.categories.length > 0 && ( + + {product.categories.map(c => c.name).join(' • ')} + + )} + {product.name} + + {/* Description — full width */} + {product.descriptionHtml && ( + + {l.application} + + {product.descriptionHtml} + + + )} + + {/* Technical Data — full-width striped table */} {product.attributes && product.attributes.length > 0 && ( - - {l.specs} + + {l.specs} - {/* Clean table header */} - - + {/* Table header */} + + {l.property} @@ -511,21 +641,49 @@ const ProductPage: React.FC<{ {product.attributes.map((attr, i) => ( - + {attr.name} - {attr.options.join(', ')} + {attr.options.join(', ')} ))} )} + + {/* QR Codes — horizontal row at bottom */} + {(product.qrWebsite || product.qrDatasheet) && ( + + {product.qrWebsite && ( + + + + + + {l.qrWeb} + {locale === 'de' ? 'Produktseite' : 'Product Page'} + + + )} + {product.qrDatasheet && ( + + + + + + {l.qrPdf} + {locale === 'de' ? 'Datenblatt' : 'Datasheet'} + + + )} + + )} ); }; @@ -584,19 +742,26 @@ const BackCover: React.FC<{ export const PDFBrochure: React.FC = ({ products, locale, companyInfo, introContent, - marketingSections, logoBlack, logoWhite, galleryImages, + marketingSections, logoBlack, logoWhite, galleryImages, messages, }) => { - // Calculate actual page numbers // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1) - const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections - const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC + const numInfoPages = 1 + (marketingSections?.length || 0); + const productStartPage = 1 + numInfoPages + 1; - // Assign images to sections: dark sections get indices 2,4; light get 3 + // Image assignment — each page gets a UNIQUE image, never repeating + // galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover + // TOC intentionally gets NO image (clean list page) + const totalGallery = galleryImages?.length || 0; + const backCoverImgIdx = totalGallery - 1; + + // Section themes: alternate light/dark const sectionThemes: Array<'light' | 'dark'> = []; + // imagePosition: alternate between top and bottom-half for variety + const imagePositions: Array<'top' | 'bottom-half'> = []; if (marketingSections) { for (let i = 0; i < marketingSections.length; i++) { - // Alternate: light, dark, light, dark, light, dark sectionThemes.push(i % 2 === 1 ? 'dark' : 'light'); + imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half'); } } @@ -604,30 +769,32 @@ export const PDFBrochure: React.FC = ({ - {/* About page with image index 1 */} - + {/* About page — image[1] */} + - {/* Each marketing section gets its own page */} + {/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */} {marketingSections?.map((section, i) => ( ))} - {/* TOC */} - + {/* TOC — no decorative image, clean list */} + {/* Products — each on its own page */} {products.map(p => ( ))} - {/* Back cover */} - + {/* Back cover — last gallery image */} + ); }; diff --git a/public/brochure/klz-product-catalog-de.pdf b/public/brochure/klz-product-catalog-de.pdf index 07ed1326..3ab5ead8 100644 Binary files a/public/brochure/klz-product-catalog-de.pdf and b/public/brochure/klz-product-catalog-de.pdf differ diff --git a/public/brochure/klz-product-catalog-en.pdf b/public/brochure/klz-product-catalog-en.pdf index 37154654..0c36bc16 100644 Binary files a/public/brochure/klz-product-catalog-en.pdf and b/public/brochure/klz-product-catalog-en.pdf differ diff --git a/scripts/generate-brochure.ts b/scripts/generate-brochure.ts index 09ffc4d5..0229773f 100644 --- a/scripts/generate-brochure.ts +++ b/scripts/generate-brochure.ts @@ -395,29 +395,39 @@ async function main(): Promise { 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 + // 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 = [ + '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)[] = []; + + 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(600).toBuffer(); + const buf = await sharp(fullPath).png({ quality: 80 }).resize(800).toBuffer(); galleryImages.push(buf); - } catch { /* skip */ } + } catch { + galleryImages.push(undefined); + } } else { - galleryImages.push(Buffer.alloc(0)); // placeholder to maintain index mapping + galleryImages.push(undefined); } } - console.log(`Gallery images loaded: ${galleryImages.filter(b => (b as Buffer).length > 0).length}`); + 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...`); @@ -430,12 +440,19 @@ async function main(): Promise { 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 */ } + try { // Render the React-PDF brochure const buffer = await renderToBuffer( React.createElement(PDFBrochure, { products, locale, companyInfo, introContent, - marketingSections, logoBlack, logoWhite, galleryImages, + marketingSections, logoBlack, logoWhite, galleryImages, messages, } as any) as any ); diff --git a/scripts/inspect-start2.ts b/scripts/inspect-start2.ts new file mode 100644 index 00000000..0258cc11 --- /dev/null +++ b/scripts/inspect-start2.ts @@ -0,0 +1,14 @@ +import { getPayload } from 'payload'; +import configPromise from '../payload.config'; + +async function main() { + const payload = await getPayload({ config: configPromise }); + const result = await payload.find({ + collection: 'pages', + where: { slug: { equals: 'start' } }, + locale: 'de', + depth: 2, + }); + console.log(JSON.stringify(result.docs[0], null, 2)); +} +main().catch(console.error);