diff --git a/app/[locale]/blog/[slug]/opengraph-image.tsx b/app/[locale]/blog/[slug]/opengraph-image.tsx index 9b4a1e43..b6d3c670 100644 --- a/app/[locale]/blog/[slug]/opengraph-image.tsx +++ b/app/[locale]/blog/[slug]/opengraph-image.tsx @@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE; export const contentType = 'image/png'; export const runtime = 'nodejs'; +async function fetchImageAsBase64(url: string) { + try { + const res = await fetch(url); + if (!res.ok) return undefined; + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const contentType = res.headers.get('content-type') || 'image/jpeg'; + return `data:${contentType};base64,${buffer.toString('base64')}`; + } catch (error) { + console.error('Failed to fetch OG image:', url, error); + return undefined; + } +} + export default async function Image({ params, }: { @@ -32,12 +46,19 @@ export default async function Image({ : `${SITE_URL}${post.frontmatter.featuredImage}` : undefined; + // Fetch image explicitly and convert to base64 because Satori sometimes struggles + // fetching remote URLs directly inside ImageResponse correctly in various environments. + let base64Image: string | undefined = undefined; + if (featuredImage) { + base64Image = await fetchImageAsBase64(featuredImage); + } + return new ImageResponse( , { ...OG_IMAGE_SIZE, diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index a5aed315..57f05a60 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -1,12 +1,18 @@ import { notFound, redirect } from 'next/navigation'; import JsonLd from '@/components/JsonLd'; import { SITE_URL } from '@/lib/schema'; -import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog'; +import { + getPostBySlug, + getAdjacentPosts, + getReadingTime, + extractLexicalHeadings, +} from '@/lib/blog'; import { Metadata } from 'next'; import Link from 'next/link'; import Image from 'next/image'; import PostNavigation from '@/components/blog/PostNavigation'; import PowerCTA from '@/components/blog/PowerCTA'; +import TableOfContents from '@/components/blog/TableOfContents'; import { Heading } from '@/components/ui'; import { setRequestLocale } from 'next-intl/server'; import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; @@ -67,6 +73,10 @@ export default async function BlogPost({ params }: BlogPostProps) { const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale); + // Convert Lexical content into a plain string to estimate reading time roughly + // Extract headings for TOC + const headings = extractLexicalHeadings(post.content?.root || post.content); + // Convert Lexical content into a plain string to estimate reading time roughly const rawTextContent = JSON.stringify(post.content); @@ -232,10 +242,10 @@ export default async function BlogPost({ params }: BlogPostProps) { - {/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */} + {/* Right Column: Sticky Sidebar - TOC */} 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 */} +