From 0cb96dfbac51797481483b6ce785d0aa47d1a1b3 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 1 Mar 2026 10:19:13 +0100 Subject: [PATCH 1/3] feat: product catalog --- app/[locale]/layout.tsx | 1 + app/[locale]/products/[...slug]/page.tsx | 10 +- app/actions/brochure.ts | 78 ++ components/BrochureCTA.tsx | 253 ++++++ components/BrochureModal.tsx | 211 +++++ components/DatasheetDownload.tsx | 14 +- components/ExcelDownload.tsx | 94 ++ components/Footer.tsx | 4 + components/ProductSidebar.tsx | 6 + components/ProductTechnicalData.tsx | 58 +- components/blog/TechnicalGrid.tsx | 46 +- data/excel/high-voltage.xlsx | Bin 0 -> 14939 bytes data/excel/low-voltage-KM.xlsx | Bin 0 -> 72603 bytes data/excel/medium-voltage-KM.xlsx | Bin 0 -> 72226 bytes data/excel/solar-cables.xlsx | Bin 0 -> 48852 bytes lib/datasheets.ts | 70 +- lib/pdf-brochure.tsx | 649 ++++++++++++++ lib/utils/technical.ts | 106 +++ messages/de.json | 22 +- messages/en.json | 20 + package.json | 4 +- public/brochure/klz-product-catalog-de.pdf | Bin 0 -> 9808937 bytes public/brochure/klz-product-catalog-en.pdf | Bin 0 -> 9809581 bytes public/datasheets/products/h1z2z2-k-de.xlsx | Bin 0 -> 22311 bytes public/datasheets/products/h1z2z2-k-en.xlsx | Bin 0 -> 22282 bytes public/datasheets/products/n2x2y-de.xlsx | Bin 0 -> 36815 bytes public/datasheets/products/n2x2y-en.xlsx | Bin 0 -> 36775 bytes public/datasheets/products/n2xfk2y-de.xlsx | Bin 0 -> 23437 bytes public/datasheets/products/n2xfk2y-en.xlsx | Bin 0 -> 23478 bytes public/datasheets/products/n2xfkld2y-de.xlsx | Bin 0 -> 22654 bytes public/datasheets/products/n2xfkld2y-en.xlsx | Bin 0 -> 22624 bytes public/datasheets/products/n2xs2y-de.xlsx | Bin 0 -> 40584 bytes public/datasheets/products/n2xs2y-en.xlsx | Bin 0 -> 40559 bytes public/datasheets/products/n2xsf2y-de.xlsx | Bin 0 -> 26517 bytes public/datasheets/products/n2xsf2y-en.xlsx | Bin 0 -> 26531 bytes .../datasheets/products/n2xsfl2y-hv-de.xlsx | Bin 0 -> 39512 bytes .../datasheets/products/n2xsfl2y-hv-en.xlsx | Bin 0 -> 39516 bytes .../datasheets/products/n2xsfl2y-mv-de.xlsx | Bin 0 -> 39535 bytes .../datasheets/products/n2xsfl2y-mv-en.xlsx | Bin 0 -> 39531 bytes public/datasheets/products/n2xsy-de.xlsx | Bin 0 -> 40486 bytes public/datasheets/products/n2xsy-en.xlsx | Bin 0 -> 40483 bytes public/datasheets/products/n2xy-de.xlsx | Bin 0 -> 38086 bytes public/datasheets/products/n2xy-en.xlsx | Bin 0 -> 38044 bytes public/datasheets/products/na2x2y-de.xlsx | Bin 0 -> 36134 bytes public/datasheets/products/na2x2y-en.xlsx | Bin 0 -> 36118 bytes public/datasheets/products/na2xfk2y-de.xlsx | Bin 0 -> 23429 bytes public/datasheets/products/na2xfk2y-en.xlsx | Bin 0 -> 23439 bytes public/datasheets/products/na2xfkld2y-de.xlsx | Bin 0 -> 22930 bytes public/datasheets/products/na2xfkld2y-en.xlsx | Bin 0 -> 22906 bytes public/datasheets/products/na2xs2y-de.xlsx | Bin 0 -> 44528 bytes public/datasheets/products/na2xs2y-en.xlsx | Bin 0 -> 44515 bytes public/datasheets/products/na2xsf2y-de.xlsx | Bin 0 -> 45525 bytes public/datasheets/products/na2xsf2y-en.xlsx | Bin 0 -> 45499 bytes .../datasheets/products/na2xsfl2y-hv-de.xlsx | Bin 0 -> 36519 bytes .../datasheets/products/na2xsfl2y-hv-en.xlsx | Bin 0 -> 44987 bytes .../datasheets/products/na2xsfl2y-mv-de.xlsx | Bin 0 -> 45027 bytes .../datasheets/products/na2xsfl2y-mv-en.xlsx | Bin 0 -> 45030 bytes public/datasheets/products/na2xsy-de.xlsx | Bin 0 -> 45407 bytes public/datasheets/products/na2xsy-en.xlsx | Bin 0 -> 45438 bytes public/datasheets/products/na2xy-de.xlsx | Bin 0 -> 38035 bytes public/datasheets/products/na2xy-en.xlsx | Bin 0 -> 38020 bytes public/datasheets/products/nay2y-de.xlsx | Bin 0 -> 38061 bytes public/datasheets/products/nay2y-en.xlsx | Bin 0 -> 38046 bytes public/datasheets/products/naycwy-de.xlsx | Bin 0 -> 36243 bytes public/datasheets/products/naycwy-en.xlsx | Bin 0 -> 36233 bytes public/datasheets/products/nayy-de.xlsx | Bin 0 -> 43178 bytes public/datasheets/products/nayy-en.xlsx | Bin 0 -> 43163 bytes public/datasheets/products/ny2y-de.xlsx | Bin 0 -> 28808 bytes public/datasheets/products/ny2y-en.xlsx | Bin 0 -> 28791 bytes public/datasheets/products/nycwy-de.xlsx | Bin 0 -> 33999 bytes public/datasheets/products/nycwy-en.xlsx | Bin 0 -> 34012 bytes public/datasheets/products/nyy-de.xlsx | Bin 0 -> 49695 bytes public/datasheets/products/nyy-en.xlsx | Bin 0 -> 49666 bytes public/logo-black.png | Bin 0 -> 28000 bytes public/logo-black.svg | 40 + public/logo-blue.png | Bin 0 -> 15402 bytes public/logo-white.png | Bin 0 -> 13263 bytes scripts/debug-cms.ts | 41 + scripts/debug-product.ts | 24 + scripts/generate-brochure.ts | 459 ++++++++++ scripts/generate-excel-datasheets.ts | 190 ++++ scripts/inspect-pages.ts | 17 + scripts/inspect-start.ts | 27 + scripts/lib/excel-data-parser.ts | 843 ++++++++++++++++++ scripts/list-pages.ts | 18 + 85 files changed, 3257 insertions(+), 48 deletions(-) create mode 100644 app/actions/brochure.ts create mode 100644 components/BrochureCTA.tsx create mode 100644 components/BrochureModal.tsx create mode 100644 components/ExcelDownload.tsx create mode 100644 data/excel/high-voltage.xlsx create mode 100644 data/excel/low-voltage-KM.xlsx create mode 100644 data/excel/medium-voltage-KM.xlsx create mode 100644 data/excel/solar-cables.xlsx create mode 100644 lib/pdf-brochure.tsx create mode 100644 lib/utils/technical.ts create mode 100644 public/brochure/klz-product-catalog-de.pdf create mode 100644 public/brochure/klz-product-catalog-en.pdf create mode 100644 public/datasheets/products/h1z2z2-k-de.xlsx create mode 100644 public/datasheets/products/h1z2z2-k-en.xlsx create mode 100644 public/datasheets/products/n2x2y-de.xlsx create mode 100644 public/datasheets/products/n2x2y-en.xlsx create mode 100644 public/datasheets/products/n2xfk2y-de.xlsx create mode 100644 public/datasheets/products/n2xfk2y-en.xlsx create mode 100644 public/datasheets/products/n2xfkld2y-de.xlsx create mode 100644 public/datasheets/products/n2xfkld2y-en.xlsx create mode 100644 public/datasheets/products/n2xs2y-de.xlsx create mode 100644 public/datasheets/products/n2xs2y-en.xlsx create mode 100644 public/datasheets/products/n2xsf2y-de.xlsx create mode 100644 public/datasheets/products/n2xsf2y-en.xlsx create mode 100644 public/datasheets/products/n2xsfl2y-hv-de.xlsx create mode 100644 public/datasheets/products/n2xsfl2y-hv-en.xlsx create mode 100644 public/datasheets/products/n2xsfl2y-mv-de.xlsx create mode 100644 public/datasheets/products/n2xsfl2y-mv-en.xlsx create mode 100644 public/datasheets/products/n2xsy-de.xlsx create mode 100644 public/datasheets/products/n2xsy-en.xlsx create mode 100644 public/datasheets/products/n2xy-de.xlsx create mode 100644 public/datasheets/products/n2xy-en.xlsx create mode 100644 public/datasheets/products/na2x2y-de.xlsx create mode 100644 public/datasheets/products/na2x2y-en.xlsx create mode 100644 public/datasheets/products/na2xfk2y-de.xlsx create mode 100644 public/datasheets/products/na2xfk2y-en.xlsx create mode 100644 public/datasheets/products/na2xfkld2y-de.xlsx create mode 100644 public/datasheets/products/na2xfkld2y-en.xlsx create mode 100644 public/datasheets/products/na2xs2y-de.xlsx create mode 100644 public/datasheets/products/na2xs2y-en.xlsx create mode 100644 public/datasheets/products/na2xsf2y-de.xlsx create mode 100644 public/datasheets/products/na2xsf2y-en.xlsx create mode 100644 public/datasheets/products/na2xsfl2y-hv-de.xlsx create mode 100644 public/datasheets/products/na2xsfl2y-hv-en.xlsx create mode 100644 public/datasheets/products/na2xsfl2y-mv-de.xlsx create mode 100644 public/datasheets/products/na2xsfl2y-mv-en.xlsx create mode 100644 public/datasheets/products/na2xsy-de.xlsx create mode 100644 public/datasheets/products/na2xsy-en.xlsx create mode 100644 public/datasheets/products/na2xy-de.xlsx create mode 100644 public/datasheets/products/na2xy-en.xlsx create mode 100644 public/datasheets/products/nay2y-de.xlsx create mode 100644 public/datasheets/products/nay2y-en.xlsx create mode 100644 public/datasheets/products/naycwy-de.xlsx create mode 100644 public/datasheets/products/naycwy-en.xlsx create mode 100644 public/datasheets/products/nayy-de.xlsx create mode 100644 public/datasheets/products/nayy-en.xlsx create mode 100644 public/datasheets/products/ny2y-de.xlsx create mode 100644 public/datasheets/products/ny2y-en.xlsx create mode 100644 public/datasheets/products/nycwy-de.xlsx create mode 100644 public/datasheets/products/nycwy-en.xlsx create mode 100644 public/datasheets/products/nyy-de.xlsx create mode 100644 public/datasheets/products/nyy-en.xlsx create mode 100644 public/logo-black.png create mode 100644 public/logo-black.svg create mode 100644 public/logo-blue.png create mode 100644 public/logo-white.png create mode 100644 scripts/debug-cms.ts create mode 100644 scripts/debug-product.ts create mode 100644 scripts/generate-brochure.ts create mode 100644 scripts/generate-excel-datasheets.ts create mode 100644 scripts/inspect-pages.ts create mode 100644 scripts/inspect-start.ts create mode 100644 scripts/lib/excel-data-parser.ts create mode 100644 scripts/list-pages.ts diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index b5cc4390..ee1367b2 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -91,6 +91,7 @@ export default async function Layout(props: { 'Home', 'Error', 'StandardPage', + 'Brochure', ]; const clientMessages: Record = {}; for (const key of clientKeys) { diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index c4be5c33..94f7964f 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -2,11 +2,12 @@ import JsonLd from '@/components/JsonLd'; import { SITE_URL } from '@/lib/schema'; import ProductSidebar from '@/components/ProductSidebar'; import ProductTabs from '@/components/ProductTabs'; +import ExcelDownload from '@/components/ExcelDownload'; import ProductTechnicalData from '@/components/ProductTechnicalData'; import RelatedProducts from '@/components/RelatedProducts'; import DatasheetDownload from '@/components/DatasheetDownload'; import { Badge, Card, Container, Heading, Section } from '@/components/ui'; -import { getDatasheetPath } from '@/lib/datasheets'; +import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets'; import { getAllProducts, getProductBySlug } from '@/lib/products'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { Metadata } from 'next'; @@ -278,6 +279,7 @@ export default async function ProductPage({ params }: ProductPageProps) { } const datasheetPath = getDatasheetPath(productSlug, locale); + const excelPath = getExcelDatasheetPath(productSlug, locale); const isFallback = (product.frontmatter as any).isFallback; const categorySlug = slug[0]; const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale); @@ -343,6 +345,7 @@ export default async function ProductPage({ params }: ProductPageProps) { productName={product.frontmatter.title} productImage={product.frontmatter.images?.[0]} datasheetPath={datasheetPath} + excelPath={excelPath} /> ); @@ -496,7 +499,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
- +
+ + {excelPath && } +
)} diff --git a/app/actions/brochure.ts b/app/actions/brochure.ts new file mode 100644 index 00000000..b44a76ed --- /dev/null +++ b/app/actions/brochure.ts @@ -0,0 +1,78 @@ +'use server'; + +import { getServerAppServices } from '@/lib/services/create-services.server'; + +export async function requestBrochureAction(formData: FormData) { + const services = getServerAppServices(); + const logger = services.logger.child({ action: 'requestBrochureAction' }); + + const { headers } = await import('next/headers'); + const requestHeaders = await headers(); + + if ('setServerContext' in services.analytics) { + (services.analytics as any).setServerContext({ + userAgent: requestHeaders.get('user-agent') || undefined, + language: requestHeaders.get('accept-language')?.split(',')[0] || undefined, + referrer: requestHeaders.get('referer') || undefined, + ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined, + }); + } + + services.analytics.track('brochure-request-attempt'); + + const email = formData.get('email') as string; + const locale = (formData.get('locale') as string) || 'en'; + + if (!email) { + logger.warn('Missing email in brochure request'); + return { success: false, error: 'Missing email address' }; + } + + // Basic email validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { success: false, error: 'Invalid email address' }; + } + + // 1. Save to CMS + try { + const { getPayload } = await import('payload'); + const configPromise = (await import('@payload-config')).default; + const payload = await getPayload({ config: configPromise }); + + await payload.create({ + collection: 'form-submissions', + data: { + name: email.split('@')[0], + email, + message: `Brochure download request (${locale})`, + type: 'brochure_download' as any, + }, + }); + + logger.info('Successfully saved brochure request to Payload CMS', { email }); + } catch (error) { + logger.error('Failed to store brochure request in Payload CMS', { error }); + services.errors.captureException(error, { action: 'payload_store_brochure_request' }); + } + + // 2. Notify via Gotify + try { + await services.notifications.notify({ + title: 'πŸ“‘ Brochure Download Request', + message: `New brochure download request from ${email} (${locale})`, + priority: 3, + }); + } catch (error) { + logger.error('Failed to send notification', { error }); + } + + // 3. Track success + services.analytics.track('brochure-request-success', { + locale, + }); + + // Return the brochure URL + const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`; + + return { success: true, brochureUrl }; +} diff --git a/components/BrochureCTA.tsx b/components/BrochureCTA.tsx new file mode 100644 index 00000000..4901c008 --- /dev/null +++ b/components/BrochureCTA.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations, useLocale } from 'next-intl'; +import { cn } from '@/components/ui/utils'; +import { requestBrochureAction } from '@/app/actions/brochure'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; + +interface Props { + className?: string; + compact?: boolean; +} + +/** + * BrochureCTA β€” Shows a button that opens a modal asking for an email address. + * The full-catalog PDF is ONLY revealed after email submission. + * No direct download link is exposed anywhere. + */ +export default function BrochureCTA({ className, compact = false }: Props) { + const t = useTranslations('Brochure'); + const locale = useLocale(); + const { trackEvent } = useAnalytics(); + const formRef = useRef(null); + + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form'); + const [url, setUrl] = useState(''); + const [err, setErr] = useState(''); + + useEffect(() => { setMounted(true); }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); }; + document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [open]); + + function openModal() { setOpen(true); } + function closeModal() { + setOpen(false); + setPhase('form'); + setUrl(''); + setErr(''); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!formRef.current) return; + setPhase('loading'); + + const fd = new FormData(formRef.current); + fd.set('locale', locale); + + try { + const res = await requestBrochureAction(fd); + if (res.success && res.brochureUrl) { + setUrl(res.brochureUrl); + setPhase('success'); + trackEvent(AnalyticsEvents.DOWNLOAD, { + file_name: `klz-product-catalog-${locale}.pdf`, + file_type: 'brochure', + location: 'brochure_modal', + }); + } else { + setErr(res.error || 'Error'); + setPhase('error'); + } + } catch { + setErr('Network error'); + setPhase('error'); + } + } + + // ── Trigger Button ───────────────────────────────────────────────── + const trigger = ( +
+ +
+ ); + + // ── Modal ────────────────────────────────────────────────────────── + const modal = mounted && open ? createPortal( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ + {/* Green top bar */} +
+ + {/* Close */} + + +
+ {/* Header */} +
+
+ + + +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ + {phase === 'success' ? ( +
+
+
+ + + +
+
+

{t('successTitle')}

+

{t('successDesc')}

+
+
+ + + + + {t('download')} + +
+ ) : ( +
+ + + + {phase === 'error' && err && ( +

{err}

+ )} + + + +

+ {t('privacyNote')} +

+
+ )} +
+
+
, + document.body, + ) : null; + + return ( + <> + {trigger} + {modal} + + ); +} diff --git a/components/BrochureModal.tsx b/components/BrochureModal.tsx new file mode 100644 index 00000000..1b3a0a32 --- /dev/null +++ b/components/BrochureModal.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations, useLocale } from 'next-intl'; +import { cn } from '@/components/ui/utils'; +import { requestBrochureAction } from '@/app/actions/brochure'; +import { useAnalytics } from './analytics/useAnalytics'; +import { AnalyticsEvents } from './analytics/analytics-events'; + +interface BrochureModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) { + const t = useTranslations('Brochure'); + const locale = useLocale(); + const { trackEvent } = useAnalytics(); + const formRef = useRef(null); + const [mounted, setMounted] = useState(false); + + const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); + const [brochureUrl, setBrochureUrl] = useState(null); + const [errorMsg, setErrorMsg] = useState(''); + + // Mount guard for SSR/portal + useEffect(() => { + setMounted(true); + }, []); + + // Close on escape + lock scroll + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleEsc); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.removeEventListener('keydown', handleEsc); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formRef.current) return; + + setState('submitting'); + setErrorMsg(''); + + try { + const formData = new FormData(formRef.current); + formData.set('locale', locale); + + const result = await requestBrochureAction(formData); + + if (result.success && result.brochureUrl) { + setState('success'); + setBrochureUrl(result.brochureUrl); + trackEvent(AnalyticsEvents.DOWNLOAD, { + file_name: `klz-product-catalog-${locale}.pdf`, + file_type: 'brochure', + location: 'brochure_modal', + }); + } else { + setState('error'); + setErrorMsg(result.error || 'Something went wrong'); + } + } catch { + setState('error'); + setErrorMsg('Network error'); + } + }; + + const handleClose = () => { + setState('idle'); + setBrochureUrl(null); + setErrorMsg(''); + onClose(); + }; + + if (!mounted || !isOpen) return null; + + const modal = ( +
+ {/* Backdrop */} +