chore(release): bump version to 2.2.6
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 2m51s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 2m51s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -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(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
image={base64Image || featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
||||
{/* Right Column: Sticky Sidebar - TOC */}
|
||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||
<div className="space-y-12">
|
||||
{/* Future Payload Table of Contents Implementation */}
|
||||
<div className="space-y-12 lg:sticky lg:top-32">
|
||||
<TableOfContents headings={headings} locale={locale} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,7 @@ export default async function Layout(props: {
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
|
||||
@@ -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) {
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
<div className="flex flex-col gap-4 max-w-2xl">
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
78
app/actions/brochure.ts
Normal file
78
app/actions/brochure.ts
Normal file
@@ -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 };
|
||||
}
|
||||
253
components/BrochureCTA.tsx
Normal file
253
components/BrochureCTA.tsx
Normal file
@@ -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<HTMLFormElement>(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 = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span>
|
||||
<span className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal = mounted && open ? createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Green top bar */}
|
||||
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}>
|
||||
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
|
||||
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
211
components/BrochureModal.tsx
Normal file
211
components/BrochureModal.tsx
Normal file
@@ -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<HTMLFormElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [brochureUrl, setBrochureUrl] = useState<string | null>(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 = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg className="h-6 w-6 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' && brochureUrl ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg className="h-5 w-5 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={brochureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import BrochureCTA from './BrochureCTA';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
@@ -187,6 +188,9 @@ export default function Footer() {
|
||||
{navT('contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="pt-2">
|
||||
<BrochureCTA compact className="opacity-80 hover:opacity-100 transition-opacity" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,27 +51,74 @@ const jsxConverters: JSXConverters = {
|
||||
heading: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
const tag = node?.tag;
|
||||
|
||||
// Extract text to generate an ID for the TOC
|
||||
// Lexical children might contain various nodes; we need a plain text representation
|
||||
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||
const id = textContent
|
||||
? textContent
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
: undefined;
|
||||
|
||||
if (tag === 'h1')
|
||||
return (
|
||||
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
|
||||
<h2
|
||||
id={id}
|
||||
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
if (tag === 'h2')
|
||||
return (
|
||||
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
|
||||
<h3
|
||||
id={id}
|
||||
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
if (tag === 'h3')
|
||||
return (
|
||||
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
|
||||
<h4
|
||||
id={id}
|
||||
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
if (tag === 'h4')
|
||||
return (
|
||||
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
|
||||
<h5
|
||||
id={id}
|
||||
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
if (tag === 'h5')
|
||||
return (
|
||||
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
|
||||
<h6
|
||||
id={id}
|
||||
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
||||
return (
|
||||
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
list: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
@@ -95,18 +142,18 @@ const jsxConverters: JSXConverters = {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
if (node?.checked != null) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
||||
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.checked}
|
||||
readOnly
|
||||
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
<div className="flex-1">{children}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
||||
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||
},
|
||||
quote: ({ node, nodesToJSX }: any) => {
|
||||
const children = nodesToJSX({ nodes: node.children });
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -45,22 +46,40 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
@@ -77,7 +96,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
@@ -102,9 +121,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||
<div
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
>
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface TechnicalGridItem {
|
||||
label: string;
|
||||
@@ -18,25 +18,41 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||
<span className="relative inline-block">
|
||||
{title}
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
BIN
data/excel/high-voltage.xlsx
Normal file
BIN
data/excel/high-voltage.xlsx
Normal file
Binary file not shown.
BIN
data/excel/low-voltage-KM.xlsx
Normal file
BIN
data/excel/low-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/solar-cables.xlsx
Normal file
BIN
data/excel/solar-cables.xlsx
Normal file
Binary file not shown.
35
lib/blog.ts
35
lib/blog.ts
@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
|
||||
return { id, text: cleanText, level };
|
||||
});
|
||||
}
|
||||
|
||||
export function extractLexicalHeadings(
|
||||
node: any,
|
||||
headings: { id: string; text: string; level: number }[] = [],
|
||||
): { id: string; text: string; level: number }[] {
|
||||
if (!node) return headings;
|
||||
|
||||
if (node.type === 'heading' && node.tag) {
|
||||
const level = parseInt(node.tag.replace('h', ''));
|
||||
const text = getTextContentFromLexical(node);
|
||||
if (text) {
|
||||
headings.push({
|
||||
id: generateHeadingId(text),
|
||||
text,
|
||||
level,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
function getTextContentFromLexical(node: any): string {
|
||||
if (node.type === 'text') {
|
||||
return node.text || '';
|
||||
}
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.map(getTextContentFromLexical).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from 'path';
|
||||
*/
|
||||
export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
|
||||
// Subdirectories to search in
|
||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
// List of patterns to try for the current locale
|
||||
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
|
||||
const patterns = [
|
||||
`${slug}-${locale}.pdf`,
|
||||
`${slug}-2-${locale}.pdf`,
|
||||
`${slug}-3-${locale}.pdf`,
|
||||
`${slug}-mv-${locale}.pdf`,
|
||||
`${slug}-hv-${locale}.pdf`,
|
||||
`${normalizedSlug}-${locale}.pdf`,
|
||||
`${normalizedSlug}-2-${locale}.pdf`,
|
||||
`${normalizedSlug}-3-${locale}.pdf`,
|
||||
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${slug}-en.pdf`,
|
||||
`${slug}-2-en.pdf`,
|
||||
`${slug}-3-en.pdf`,
|
||||
`${slug}-mv-en.pdf`,
|
||||
`${slug}-hv-en.pdf`,
|
||||
`${normalizedSlug}-en.pdf`,
|
||||
`${normalizedSlug}-2-en.pdf`,
|
||||
`${normalizedSlug}-3-en.pdf`,
|
||||
`${normalizedSlug}-mv-en.pdf`,
|
||||
`${normalizedSlug}-hv-en.pdf`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the datasheet Excel path for a given product slug and locale.
|
||||
* Checks public/datasheets for matching .xlsx files.
|
||||
*/
|
||||
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
const patterns = [
|
||||
`${slug}-${locale}.xlsx`,
|
||||
`${slug}-2-${locale}.xlsx`,
|
||||
`${slug}-3-${locale}.xlsx`,
|
||||
`${normalizedSlug}-${locale}.xlsx`,
|
||||
`${normalizedSlug}-2-${locale}.xlsx`,
|
||||
`${normalizedSlug}-3-${locale}.xlsx`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of patterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enPatterns = [
|
||||
`${slug}-en.xlsx`,
|
||||
`${slug}-2-en.xlsx`,
|
||||
`${slug}-3-en.xlsx`,
|
||||
`${normalizedSlug}-en.xlsx`,
|
||||
`${normalizedSlug}-2-en.xlsx`,
|
||||
`${normalizedSlug}-3-en.xlsx`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
|
||||
692
lib/pdf-brochure.tsx
Normal file
692
lib/pdf-brochure.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// ─── Brand Colors ───────────────────────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
primary: '#001a4d', // Navy
|
||||
primaryDark: '#000d26', // Deepest Navy
|
||||
saturated: '#0117bf',
|
||||
accent: '#4da612', // Green
|
||||
accentLight: '#e8f5d8',
|
||||
black: '#000000',
|
||||
white: '#FFFFFF',
|
||||
gray050: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
// ─── Spacing Scale ──────────────────────────────────────────────────────────
|
||||
|
||||
const S = { xs: 4, sm: 8, md: 16, lg: 24, xl: 40, xxl: 56 } as const;
|
||||
|
||||
const M = { h: 72, bottom: 96 } as const;
|
||||
const HEADER_H = 64;
|
||||
const PAGE_TOP_PADDING = 110;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BrochureProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml?: string;
|
||||
images: string[];
|
||||
featuredImage: string | null;
|
||||
sku: string;
|
||||
slug: string;
|
||||
categories: Array<{ name: string }>;
|
||||
attributes: Array<{ name: string; options: string[] }>;
|
||||
qrWebsite?: string | Buffer;
|
||||
qrDatasheet?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface BrochureProps {
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: {
|
||||
tagline: string;
|
||||
values: Array<{ title: string; description: string }>;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
};
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
|
||||
marketingSections?: Array<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
items?: Array<{ title: string; description: string }>;
|
||||
highlights?: Array<{ value: string; label: string }>;
|
||||
pullQuote?: string;
|
||||
}>;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const L = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||
catalog: 'Produktkatalog', subtitle: 'Hochwertige Stromkabel · Mittelspannungslösungen · Solarkabel',
|
||||
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.',
|
||||
} : {
|
||||
catalog: 'Product Catalog', subtitle: 'High-Quality Power Cables · Medium Voltage Solutions · Solar 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.',
|
||||
};
|
||||
|
||||
// ─── Text tokens ────────────────────────────────────────────────────────────
|
||||
|
||||
const T = {
|
||||
label: (d: boolean) => ({ fontSize: 9, fontWeight: 700, color: C.accent, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1.2 }),
|
||||
sectionTitle: (d: boolean) => ({ fontSize: 24, fontWeight: 700, color: d ? C.white : C.primaryDark, letterSpacing: -0.5 }),
|
||||
body: (d: boolean) => ({ fontSize: 10, color: d ? C.gray300 : C.gray600, lineHeight: 1.7 }),
|
||||
bodyLead: (d: boolean) => ({ fontSize: 13, color: d ? C.white : C.gray900, lineHeight: 1.8 }),
|
||||
bodyBold: (d: boolean) => ({ fontSize: 10, fontWeight: 700, color: d ? C.white : C.primaryDark }),
|
||||
caption: (d: boolean) => ({ fontSize: 8, color: d ? C.gray400 : C.gray400, textTransform: 'uppercase' as 'uppercase', letterSpacing: 1 }),
|
||||
};
|
||||
|
||||
// ─── Rich Text (supports **bold** and *italic*) ────────────────────────────
|
||||
|
||||
const RichText: React.FC<{ children: string; style?: any; paragraphGap?: number; isDark?: boolean; asParagraphs?: boolean }> = ({ children, style = {}, paragraphGap = 8, isDark = false, asParagraphs = true }) => {
|
||||
const paragraphs = asParagraphs ? children.split('\n\n').filter(p => p.trim()) : [children];
|
||||
return (
|
||||
<View style={{ gap: paragraphGap }}>
|
||||
{paragraphs.map((para, pIdx) => {
|
||||
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
|
||||
let remaining = para;
|
||||
while (remaining.length > 0) {
|
||||
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
|
||||
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
|
||||
const firstMatch = [boldMatch, italicMatch].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
|
||||
if (!firstMatch || firstMatch.index === undefined) { parts.push({ text: remaining }); break; }
|
||||
if (firstMatch.index > 0) parts.push({ text: remaining.substring(0, firstMatch.index) });
|
||||
parts.push({ text: firstMatch[1], bold: firstMatch[0].startsWith('**'), italic: !firstMatch[0].startsWith('**') });
|
||||
remaining = remaining.substring(firstMatch.index + firstMatch[0].length);
|
||||
}
|
||||
return (
|
||||
<Text key={pIdx} style={style}>
|
||||
{parts.map((part, i) => (
|
||||
<Text key={i} style={{
|
||||
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: isDark ? C.white : C.primaryDark } : {}),
|
||||
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.accent } : {}),
|
||||
}}>{part.text}</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Reusable Components ────────────────────────────────────────────────────
|
||||
|
||||
const FixedHeader: React.FC<{ logoWhite?: string | Buffer; logoBlack?: string | Buffer; rightText?: string; isDark?: boolean }> = ({ logoWhite, logoBlack, rightText, isDark }) => {
|
||||
const logo = isDark ? (logoWhite || logoBlack) : logoBlack;
|
||||
return (
|
||||
<View style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
|
||||
paddingHorizontal: M.h, paddingTop: 24, paddingBottom: 16,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderBottomWidth: 0.5, borderBottomColor: isDark ? 'rgba(255,255,255,0.1)' : C.gray300, borderBottomStyle: 'solid',
|
||||
backgroundColor: isDark ? C.primaryDark : C.white,
|
||||
}} fixed>
|
||||
{logo ? <Image src={logo} style={{ width: 64 }} /> : <Text style={{ fontSize: 16, fontWeight: 700, color: isDark ? C.white : C.primaryDark }}>KLZ</Text>}
|
||||
{rightText && <Text style={{ fontSize: 8, fontWeight: 700, color: isDark ? C.white : C.primary, letterSpacing: 0.8, textTransform: 'uppercase' }}>{rightText}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer: React.FC<{ left: string; right: string; logoWhite?: string | Buffer; logoBlack?: string | Buffer; isDark?: boolean }> = ({ left, right, logoWhite, logoBlack, isDark }) => {
|
||||
const logo = isDark ? (logoWhite || logoBlack) : logoBlack;
|
||||
return (
|
||||
<View style={{
|
||||
position: 'absolute', bottom: 40, left: M.h, right: M.h,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 0.5, borderTopColor: isDark ? 'rgba(255,255,255,0.1)' : C.gray300, borderTopStyle: 'solid',
|
||||
}} fixed>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
{logo && <Image src={logo} style={{ width: 40, height: 'auto' }} />}
|
||||
<Text style={T.caption(!!isDark)}>{left}</Text>
|
||||
</View>
|
||||
<Text style={T.caption(!!isDark)}>{right}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionHeading: React.FC<{ label?: string; title: string, isDark: boolean }> = ({ label, title, isDark }) => (
|
||||
<View style={{ marginBottom: S.lg }} minPresenceAhead={120}>
|
||||
{label && <Text style={{ ...T.label(isDark), marginBottom: S.sm }}>{label}</Text>}
|
||||
<Text style={{ ...T.sectionTitle(isDark), marginBottom: S.md }}>{title}</Text>
|
||||
<View style={{ width: 48, height: 4, backgroundColor: C.accent }} />
|
||||
</View>
|
||||
);
|
||||
|
||||
// Pull-quote callout block
|
||||
const PullQuote: React.FC<{ quote: string, isDark: boolean }> = ({ quote, isDark }) => (
|
||||
<View style={{
|
||||
marginVertical: S.xl,
|
||||
paddingLeft: S.lg,
|
||||
borderLeftWidth: 4, borderLeftColor: C.accent, borderLeftStyle: 'solid',
|
||||
}}>
|
||||
<Text style={{ fontSize: 16, fontWeight: 700, color: isDark ? C.white : C.primaryDark, lineHeight: 1.5, letterSpacing: -0.2 }}>
|
||||
„{quote}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Stat highlight boxes
|
||||
const HighlightRow: React.FC<{ highlights: Array<{ value: string; label: string }>, isDark: boolean }> = ({ highlights, isDark }) => (
|
||||
<View style={{ flexDirection: 'row', gap: S.md, marginVertical: S.lg }}>
|
||||
{highlights.map((h, i) => (
|
||||
<View key={i} style={{
|
||||
flex: 1,
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : C.gray050,
|
||||
borderLeftWidth: 3, borderLeftColor: C.accent, borderLeftStyle: 'solid',
|
||||
paddingVertical: S.md, paddingHorizontal: S.md,
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: isDark ? C.white : C.primaryDark, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 9, color: isDark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
// Magazine Edge-to-Edge Image
|
||||
// By using negative horizontal margin (-M.h) matching the parent padding, it touches the very edges.
|
||||
// By using negative vertical margin matching the vertical padding, it touches the top or bottom of the colored block!
|
||||
const MagazineImage: React.FC<{ src: string | Buffer; height?: number; position: 'top' | 'bottom' | 'middle'; isDark?: boolean }> = ({ src, height = 260, position, isDark }) => {
|
||||
if (Buffer.isBuffer(src) && src.length === 0) return null;
|
||||
|
||||
const marginTop = position === 'top' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl);
|
||||
const marginBottom = position === 'bottom' ? -S.xxl : (position === 'middle' ? S.xl : S.xxl);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
marginHorizontal: -M.h,
|
||||
height,
|
||||
marginTop,
|
||||
marginBottom,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isDark && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.2 }} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Magazine Block wrapper
|
||||
const MagazineSection: React.FC<{
|
||||
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||
image?: string | Buffer;
|
||||
theme: 'white' | 'gray' | 'dark';
|
||||
imagePosition: 'top' | 'bottom' | 'middle';
|
||||
}> = ({ section, image, theme, imagePosition }) => {
|
||||
const isDark = theme === 'dark';
|
||||
const bgColor = theme === 'white' ? C.white : (theme === 'gray' ? C.gray050 : C.primaryDark);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
marginHorizontal: -M.h,
|
||||
paddingHorizontal: M.h,
|
||||
paddingVertical: S.xxl,
|
||||
backgroundColor: bgColor,
|
||||
}} wrap={true}>
|
||||
{image && imagePosition === 'top' && <MagazineImage src={image} height={280} position="top" isDark={isDark} />}
|
||||
|
||||
<SectionHeading label={section.subtitle} title={section.title} isDark={isDark} />
|
||||
|
||||
{section.description && (
|
||||
<RichText style={T.bodyLead(isDark)} paragraphGap={S.md} isDark={isDark}>
|
||||
{section.description}
|
||||
</RichText>
|
||||
)}
|
||||
|
||||
{image && imagePosition === 'middle' && <MagazineImage src={image} height={220} position="middle" isDark={isDark} />}
|
||||
|
||||
{section.highlights && section.highlights.length > 0 && (
|
||||
<HighlightRow highlights={section.highlights} isDark={isDark} />
|
||||
)}
|
||||
|
||||
{section.pullQuote && <PullQuote quote={section.pullQuote} isDark={isDark} />}
|
||||
|
||||
{section.items && section.items.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: S.xl, marginTop: S.xl }}>
|
||||
{section.items.map((item, i) => (
|
||||
<View key={i} style={{ width: '45%', marginBottom: S.sm }} minPresenceAhead={60}>
|
||||
<View style={{ width: 24, height: 2, backgroundColor: C.accent, marginBottom: S.sm }} />
|
||||
<Text style={{ ...T.bodyBold(isDark), fontSize: 11, marginBottom: 4 }}>{item.title}</Text>
|
||||
<RichText style={{ ...T.body(isDark) }} asParagraphs={false} isDark={isDark}>
|
||||
{item.description}
|
||||
</RichText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{image && imagePosition === 'bottom' && <MagazineImage src={image} height={320} position="bottom" isDark={isDark} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Cover Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CoverPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
introContent?: BrochureProps['introContent'];
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}> = ({ locale, introContent, logoWhite, logoBlack, galleryImages }) => {
|
||||
const l = L(locale);
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
||||
const bgImage = galleryImages?.[0] || introContent?.heroImage;
|
||||
const logo = logoWhite || logoBlack;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ backgroundColor: C.primaryDark, fontFamily: 'Helvetica' }}>
|
||||
{bgImage && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={bgImage} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.85 }} />
|
||||
</View>
|
||||
)}
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, width: 6, height: 320, backgroundColor: C.accent }} />
|
||||
|
||||
<View style={{ flex: 1, paddingHorizontal: M.h }}>
|
||||
<View style={{ marginTop: 80 }}>
|
||||
{logo ? <Image src={logo} style={{ width: 140 }} /> : <Text style={{ fontSize: 28, fontWeight: 700, color: C.white }}>KLZ</Text>}
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 180 }}>
|
||||
<View style={{ width: 48, height: 4, backgroundColor: C.accent, borderRadius: 2, marginBottom: S.xl }} />
|
||||
<Text style={{ fontSize: 48, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: 0.5, lineHeight: 1.1, marginBottom: S.md }}>
|
||||
{l.catalog}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, color: C.gray300, lineHeight: 1.6, maxWidth: 360 }}>
|
||||
{introContent?.excerpt || l.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ position: 'absolute', bottom: S.xxl, left: M.h, right: M.h, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 10, color: C.gray400, textTransform: 'uppercase', letterSpacing: 1 }}>{l.edition} {dateStr}</Text>
|
||||
<Text style={{ fontSize: 10, color: C.white, fontWeight: 700 }}>www.klz-cables.com</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Info Flow ──────────────────────────────────────────────────────────────
|
||||
|
||||
const InfoFlow: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
marketingSections?: BrochureProps['marketingSections'];
|
||||
logoBlack?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}> = ({ locale, companyInfo, marketingSections, logoBlack, galleryImages }) => {
|
||||
const l = L(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" wrap style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}>
|
||||
<FixedHeader logoBlack={logoBlack} rightText="KLZ Cables" isDark={false} />
|
||||
<Footer left="KLZ Cables" right={companyInfo.website} logoBlack={logoBlack} isDark={false} />
|
||||
|
||||
<View style={{ paddingHorizontal: M.h }}>
|
||||
|
||||
{/* ── About KLZ ── */}
|
||||
<View style={{
|
||||
marginHorizontal: -M.h, paddingHorizontal: M.h,
|
||||
paddingTop: S.xxl, paddingBottom: S.xl,
|
||||
backgroundColor: C.white,
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', gap: S.xl }}>
|
||||
<View style={{ flex: 1.5 }}>
|
||||
<SectionHeading label={l.about} title="KLZ Cables" isDark={false} />
|
||||
<RichText style={{ ...T.bodyLead(false), fontSize: 14, lineHeight: 1.6 }} paragraphGap={S.md} isDark={false}>
|
||||
{companyInfo.tagline}
|
||||
</RichText>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
{galleryImages?.[1] && (
|
||||
<View style={{ width: '100%', height: 240, borderLeftWidth: 4, borderLeftColor: C.accent, borderLeftStyle: 'solid' }}>
|
||||
<Image src={galleryImages[1]} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: S.xxl }}>
|
||||
<Text style={{ ...T.label(false), marginBottom: S.md }}>{l.values}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: S.lg }}>
|
||||
{companyInfo.values.map((v, i) => (
|
||||
<View key={i} style={{ width: '47%', marginBottom: S.md }} minPresenceAhead={40}>
|
||||
<Text style={{ ...T.bodyBold(false), fontSize: 11, marginBottom: 4 }}>
|
||||
<Text style={{ color: C.accent }}>0{i + 1}</Text> {'\u00A0'} {v.title}
|
||||
</Text>
|
||||
<Text style={{ ...T.body(false), fontSize: 9 }}>{v.description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Marketing Sections ── */}
|
||||
{marketingSections?.map((section, sIdx) => {
|
||||
const themes: Array<'white' | 'gray' | 'dark'> = ['gray', 'dark', 'white', 'gray', 'white', 'dark'];
|
||||
const imagePositions: Array<'top' | 'bottom' | 'middle'> = ['bottom', 'top', 'bottom', 'middle', 'middle', 'top'];
|
||||
const theme = themes[sIdx % themes.length];
|
||||
const pos = imagePositions[sIdx % imagePositions.length];
|
||||
const img = galleryImages?.[sIdx + 2];
|
||||
|
||||
return (
|
||||
<MagazineSection
|
||||
key={`m-${sIdx}`}
|
||||
section={section}
|
||||
image={img}
|
||||
theme={theme}
|
||||
imagePosition={pos}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── TOC Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TocPage: React.FC<{
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
productStartPage: number;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}> = ({ products, locale, logoBlack, productStartPage, galleryImages }) => {
|
||||
const l = L(locale);
|
||||
|
||||
const grouped = new Map<string, Array<{ product: BrochureProduct, pageNum: number }>>();
|
||||
let currentGlobalIdx = 0;
|
||||
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 + currentGlobalIdx });
|
||||
currentGlobalIdx++;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}>
|
||||
<FixedHeader logoBlack={logoBlack} rightText={l.overview} />
|
||||
<Footer left="KLZ Cables" right="www.klz-cables.com" logoBlack={logoBlack} />
|
||||
|
||||
<View style={{ paddingHorizontal: M.h }}>
|
||||
<SectionHeading label={l.catalog} title={l.toc} isDark={false} />
|
||||
|
||||
{/* Decorative image edge-to-edge */}
|
||||
{galleryImages?.[5] && (
|
||||
<View style={{ marginHorizontal: -M.h, height: 160, marginBottom: S.xxl }}>
|
||||
<Image src={galleryImages[5]} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'column' }}>
|
||||
{Array.from(grouped.entries()).map(([cat, items]) => (
|
||||
<View key={cat} style={{ width: '100%', marginBottom: S.md }}>
|
||||
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.primaryDark, borderBottomStyle: 'solid', paddingBottom: S.xs, marginBottom: S.sm }}>
|
||||
<Text style={{ ...T.label(false) }}>{cat}</Text>
|
||||
</View>
|
||||
{items.map((item, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingVertical: 5,
|
||||
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<Text style={{ fontSize: 11, fontWeight: 700, color: C.primaryDark }}>{item.product.name}</Text>
|
||||
<Text style={{ fontSize: 10, color: C.gray400 }}>{l.page} {item.pageNum}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Product Block ──────────────────────────────────────────────────────────
|
||||
|
||||
const ProductBlock: React.FC<{
|
||||
product: BrochureProduct;
|
||||
locale: 'en' | 'de';
|
||||
}> = ({ product, locale }) => {
|
||||
const l = L(locale);
|
||||
const desc = stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
|
||||
const LABEL_WIDTH = 260; // Wider label container for technical data
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SectionHeading
|
||||
label={product.categories.map(c => c.name).join(' · ')}
|
||||
title={product.name}
|
||||
isDark={false}
|
||||
/>
|
||||
|
||||
{/* Edge-to-edge product image */}
|
||||
<View style={{
|
||||
marginHorizontal: -M.h,
|
||||
height: 200, justifyContent: 'center', alignItems: 'center',
|
||||
backgroundColor: C.gray050,
|
||||
borderTopWidth: 0.5, borderTopColor: C.gray200, borderTopStyle: 'solid',
|
||||
borderBottomWidth: 0.5, borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
marginBottom: S.xl, padding: S.lg
|
||||
}}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description + QR */}
|
||||
<View style={{ flexDirection: 'row', gap: S.xl, marginBottom: S.xl }}>
|
||||
<View style={{ flex: 1.8 }}>
|
||||
{desc && (
|
||||
<View>
|
||||
<Text style={{ ...T.label(false), marginBottom: S.md }}>{l.application}</Text>
|
||||
<RichText style={{ ...T.body(false), lineHeight: 1.8 }}>{desc}</RichText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flex: 1, flexDirection: 'column', justifyContent: 'flex-start' }}>
|
||||
{(product.qrWebsite || product.qrDatasheet) && (
|
||||
<View style={{ flexDirection: 'column', gap: S.md }}>
|
||||
{product.qrWebsite && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: S.md }}>
|
||||
<View style={{ padding: 4, borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid' }}>
|
||||
<Image src={product.qrWebsite} style={{ width: 44, height: 44 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ ...T.caption(false), fontWeight: 700, color: C.primaryDark, marginBottom: 2 }}>{l.qrWeb}</Text>
|
||||
<Text style={{ fontSize: 8, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{product.qrDatasheet && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: S.md }}>
|
||||
<View style={{ padding: 4, borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid' }}>
|
||||
<Image src={product.qrDatasheet} style={{ width: 44, height: 44 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ ...T.caption(false), fontWeight: 700, color: C.primaryDark, marginBottom: 2 }}>{l.qrPdf}</Text>
|
||||
<Text style={{ fontSize: 8, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Technical Data Table — clean, minimal header, wider labels */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View>
|
||||
<Text style={{ ...T.label(false), marginBottom: S.sm }}>{l.specs}</Text>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1.5, borderBottomColor: C.primaryDark, borderBottomStyle: 'solid',
|
||||
paddingBottom: S.xs, marginBottom: 4,
|
||||
}}>
|
||||
<View style={{ width: LABEL_WIDTH, paddingHorizontal: 10 }}>
|
||||
<Text style={{ ...T.label(false), fontSize: 7, color: C.gray600 }}>
|
||||
{locale === 'de' ? 'Eigenschaft' : 'Property'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1, paddingHorizontal: 10 }}>
|
||||
<Text style={{ ...T.label(false), fontSize: 7, color: C.gray600 }}>
|
||||
{locale === 'de' ? 'Wert' : 'Value'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rows */}
|
||||
{product.attributes.map((attr, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
backgroundColor: i % 2 === 0 ? C.white : C.gray050,
|
||||
paddingVertical: 6,
|
||||
}}>
|
||||
<View style={{
|
||||
width: LABEL_WIDTH, paddingHorizontal: 10,
|
||||
borderRightWidth: 1, borderRightColor: C.gray200, borderRightStyle: 'solid',
|
||||
}}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.primaryDark, letterSpacing: 0.2 }}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1, paddingHorizontal: 10, justifyContent: 'center' }}>
|
||||
<Text style={{ fontSize: 10, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Products Flow ──────────────────────────────────────────────────────────
|
||||
|
||||
const ProductsFlow: React.FC<{
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
}> = ({ products, locale, logoBlack }) => {
|
||||
const l = L(locale);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{products.map((p) => (
|
||||
<Page key={p.id} size="A4" style={{ backgroundColor: C.white, fontFamily: 'Helvetica', paddingTop: PAGE_TOP_PADDING, paddingBottom: M.bottom }}>
|
||||
<FixedHeader logoBlack={logoBlack} rightText={l.overview} />
|
||||
<Footer left="KLZ Cables" right="www.klz-cables.com" logoBlack={logoBlack} />
|
||||
<View style={{ paddingHorizontal: M.h }}>
|
||||
<ProductBlock product={p} locale={locale} />
|
||||
</View>
|
||||
</Page>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Back Cover ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BackCoverPage: React.FC<{
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
locale: 'en' | 'de';
|
||||
logoWhite?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}> = ({ companyInfo, locale, logoWhite, galleryImages }) => {
|
||||
const l = L(locale);
|
||||
const bgImage = galleryImages?.[6];
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ backgroundColor: C.primaryDark, fontFamily: 'Helvetica' }}>
|
||||
{bgImage && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={bgImage} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.primaryDark, opacity: 0.9 }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: M.h }}>
|
||||
{logoWhite ? (
|
||||
<Image src={logoWhite} style={{ width: 180, marginBottom: S.xl }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, letterSpacing: 2, textTransform: 'uppercase', marginBottom: S.xl }}>KLZ CABLES</Text>
|
||||
)}
|
||||
|
||||
<View style={{ width: 48, height: 4, backgroundColor: C.accent, borderRadius: 2, marginBottom: S.xl }} />
|
||||
|
||||
<View style={{ alignItems: 'center', marginBottom: S.lg }}>
|
||||
<Text style={{ ...T.label(true), marginBottom: S.sm }}>{l.contact}</Text>
|
||||
<Text style={{ fontSize: 13, color: C.white, lineHeight: 1.7, textAlign: 'center' }}>{companyInfo.address}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'center', marginBottom: S.lg }}>
|
||||
<Text style={{ fontSize: 13, color: C.white }}>{companyInfo.phone}</Text>
|
||||
<Text style={{ fontSize: 13, color: C.gray300 }}>{companyInfo.email}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 14, fontWeight: 700, color: C.accent, marginTop: S.lg }}>{companyInfo.website}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ position: 'absolute', bottom: 40, left: M.h, right: M.h, flexDirection: 'row', justifyContent: 'center' }} fixed>
|
||||
<Text style={{ fontSize: 9, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main Document ──────────────────────────────────────────────────────────
|
||||
|
||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
||||
}) => {
|
||||
const productStartPage = 5;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<CoverPage locale={locale} introContent={introContent} logoBlack={logoBlack} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||
<InfoFlow locale={locale} companyInfo={companyInfo} marketingSections={marketingSections} logoBlack={logoBlack} galleryImages={galleryImages} />
|
||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} galleryImages={galleryImages} />
|
||||
<ProductsFlow products={products} locale={locale} logoBlack={logoBlack} />
|
||||
<BackCoverPage companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
106
lib/utils/technical.ts
Normal file
106
lib/utils/technical.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility for formatting technical data values.
|
||||
* Handles long lists of standards and simplifies repetitive strings.
|
||||
*/
|
||||
|
||||
export interface FormattedTechnicalValue {
|
||||
original: string;
|
||||
isList: boolean;
|
||||
parts: string[];
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a technical value string.
|
||||
* Detects if it's a list (separated by / or ,) and tries to clean it up.
|
||||
*/
|
||||
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
|
||||
if (!value) {
|
||||
return { original: '', isList: false, parts: [], displayValue: '' };
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
|
||||
// Detect list separators
|
||||
let parts: string[] = [];
|
||||
if (str.includes(' / ')) {
|
||||
parts = str.split(' / ').map(p => p.trim());
|
||||
} else if (str.includes(' /')) {
|
||||
parts = str.split(' /').map(p => p.trim());
|
||||
} else if (str.includes('/ ')) {
|
||||
parts = str.split('/ ').map(p => p.trim());
|
||||
} else if (str.split('/').length > 2) {
|
||||
// Check if it's actually many standards separated by / without spaces
|
||||
// e.g. EN123/EN456/EN789
|
||||
const split = str.split('/');
|
||||
if (split.length > 3) {
|
||||
parts = split.map(p => p.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// If no parts found yet, try comma
|
||||
if (parts.length === 0 && str.includes(', ')) {
|
||||
parts = str.split(', ').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Filter out empty parts
|
||||
parts = parts.filter(Boolean);
|
||||
|
||||
// If we have parts, let's see if we can simplify them
|
||||
if (parts.length > 2) {
|
||||
// Find common prefix to condense repetitive standards
|
||||
let commonPrefix = '';
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
let i = 0;
|
||||
while (i < first.length && first.charAt(i) === last.charAt(i)) {
|
||||
i++;
|
||||
}
|
||||
commonPrefix = first.substring(0, i);
|
||||
|
||||
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
|
||||
if (commonPrefix.length > 4) {
|
||||
// Trim trailing spaces/dashes before comparing words
|
||||
const basePrefix = commonPrefix.trim();
|
||||
const suffixParts: string[] = [];
|
||||
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
if (idx === 0) {
|
||||
suffixParts.push(parts[idx]);
|
||||
} else {
|
||||
const suffix = parts[idx].substring(commonPrefix.length).trim();
|
||||
if (suffix) {
|
||||
suffixParts.push(suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
|
||||
// Wait, returning a single string might still wrap badly.
|
||||
// Instead, we return them as chunks or just a condensed string.
|
||||
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false, // Turn off badge rendering to use text block instead
|
||||
parts: [condensedString],
|
||||
displayValue: condensedString
|
||||
};
|
||||
}
|
||||
|
||||
// If no common prefix, return as list so UI can render badges
|
||||
return {
|
||||
original: str,
|
||||
isList: true,
|
||||
parts,
|
||||
displayValue: parts.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false,
|
||||
parts: [str],
|
||||
displayValue: str
|
||||
};
|
||||
}
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||
"downloadDatasheet": "Datenblatt herunterladen",
|
||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
||||
"downloadExcel": "Excel herunterladen",
|
||||
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
|
||||
"downloadBrochure": "Produktbroschüre",
|
||||
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
|
||||
"form": {
|
||||
"contactInfo": "Kontaktinformationen",
|
||||
"projectDetails": "Projektdetails",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Produktkatalog",
|
||||
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
|
||||
"emailPlaceholder": "ihre@email.de",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"submit": "Broschüre erhalten",
|
||||
"submitting": "Wird gesendet...",
|
||||
"successTitle": "Ihre Broschüre ist bereit!",
|
||||
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
|
||||
"download": "Broschüre herunterladen",
|
||||
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||
"close": "Schließen",
|
||||
"ctaTitle": "Kompletter Produktkatalog",
|
||||
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
|
||||
"ctaButton": "Kostenlose Broschüre erhalten"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||
"downloadDatasheet": "Download Datasheet",
|
||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
||||
"downloadExcel": "Download Excel",
|
||||
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
|
||||
"downloadBrochure": "Product Brochure",
|
||||
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
|
||||
"form": {
|
||||
"contactInfo": "Contact Information",
|
||||
"projectDetails": "Project Details",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Product Catalog",
|
||||
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"emailLabel": "Email Address",
|
||||
"submit": "Get Brochure",
|
||||
"submitting": "Sending...",
|
||||
"successTitle": "Your brochure is ready!",
|
||||
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
|
||||
"download": "Download Brochure",
|
||||
"privacyNote": "By submitting you agree to our privacy policy.",
|
||||
"close": "Close",
|
||||
"ctaTitle": "Complete Product Catalog",
|
||||
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
|
||||
"ctaButton": "Get Free Brochure"
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,8 @@
|
||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||
"cms:migrate": "payload migrate",
|
||||
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||
@@ -139,7 +141,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
@@ -161,4 +163,4 @@
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.563.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
Binary file not shown.
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/products/h1z2z2-k-de.xlsx
Normal file
BIN
public/datasheets/products/h1z2z2-k-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/h1z2z2-k-en.xlsx
Normal file
BIN
public/datasheets/products/h1z2z2-k-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2x2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2x2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2x2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2x2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfk2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xfk2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfk2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xfk2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfkld2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xfkld2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfkld2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xfkld2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xs2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xs2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xs2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xs2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsf2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsf2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsf2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsf2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-hv-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-hv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-hv-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-hv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-mv-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-mv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-mv-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-mv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsy-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsy-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xy-de.xlsx
Normal file
BIN
public/datasheets/products/n2xy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xy-en.xlsx
Normal file
BIN
public/datasheets/products/n2xy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2x2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2x2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2x2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2x2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfk2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xfk2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfk2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xfk2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfkld2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xfkld2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfkld2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xfkld2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xs2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xs2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xs2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xs2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsf2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsf2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsf2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsf2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-hv-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-hv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-hv-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-hv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-mv-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-mv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-mv-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-mv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsy-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsy-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xy-de.xlsx
Normal file
BIN
public/datasheets/products/na2xy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xy-en.xlsx
Normal file
BIN
public/datasheets/products/na2xy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nay2y-de.xlsx
Normal file
BIN
public/datasheets/products/nay2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nay2y-en.xlsx
Normal file
BIN
public/datasheets/products/nay2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/naycwy-de.xlsx
Normal file
BIN
public/datasheets/products/naycwy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/naycwy-en.xlsx
Normal file
BIN
public/datasheets/products/naycwy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nayy-de.xlsx
Normal file
BIN
public/datasheets/products/nayy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nayy-en.xlsx
Normal file
BIN
public/datasheets/products/nayy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/ny2y-de.xlsx
Normal file
BIN
public/datasheets/products/ny2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/ny2y-en.xlsx
Normal file
BIN
public/datasheets/products/ny2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nycwy-de.xlsx
Normal file
BIN
public/datasheets/products/nycwy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nycwy-en.xlsx
Normal file
BIN
public/datasheets/products/nycwy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nyy-de.xlsx
Normal file
BIN
public/datasheets/products/nyy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nyy-en.xlsx
Normal file
BIN
public/datasheets/products/nyy-en.xlsx
Normal file
Binary file not shown.
BIN
public/logo-black.png
Normal file
BIN
public/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
40
public/logo-black.svg
Normal file
40
public/logo-black.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 295 99" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,0.000798697,0)">
|
||||
<path d="M83.219,92.879C83.219,93.629 82.973,94.043 81.992,94.043C81.008,94.043 80.82,93.629 80.82,92.91L80.82,89.969C80.82,89.25 81.008,88.836 81.992,88.836C83.043,88.836 83.219,89.25 83.219,89.988L84.578,89.988C84.578,88.305 83.82,87.637 81.992,87.637C80.16,87.637 79.461,88.297 79.461,89.898L79.461,92.98C79.461,94.543 80.191,95.242 81.992,95.242C83.793,95.242 84.578,94.543 84.578,92.879L83.219,92.879Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M90.543,87.656L89.195,87.656L87.102,95.223L88.496,95.223L88.891,93.883L90.828,93.883L91.211,95.223L92.609,95.223L90.543,87.656ZM89.227,92.555L89.855,89.754L90.484,92.555L89.227,92.555Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M95.336,95.223L97.836,95.223C99.668,95.223 100.523,94.574 100.523,92.871C100.523,91.828 99.922,91.148 99.137,90.98C99.734,90.578 99.824,90.117 99.824,89.652C99.824,88.473 98.957,87.648 97.59,87.648L95.336,87.648L95.336,95.223ZM96.688,91.809L97.836,91.809C98.82,91.809 99.066,92.152 99.066,92.898C99.066,93.617 98.91,93.992 97.855,93.992L96.688,93.992L96.688,91.809ZM97.59,88.809C98.258,88.809 98.426,89.289 98.426,89.672C98.426,90.156 98.16,90.559 97.602,90.559L96.695,90.559L96.695,88.809L97.59,88.809Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M107.906,93.98L104.98,93.98L104.98,87.648L103.613,87.648L103.613,95.223L107.906,95.223L107.906,93.98Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M110.879,87.648L110.879,95.23L115.375,95.23L115.375,93.992L112.238,93.992L112.238,91.996L114.793,91.996L114.793,90.773L112.238,90.773L112.238,88.828L115.238,88.828L115.238,87.648L110.879,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M121.684,89.625L123.051,89.625C122.926,88.109 122.02,87.605 120.652,87.605C119.098,87.605 118.23,88.344 118.23,89.762C118.23,91.324 119.137,91.75 119.992,91.855C120.797,91.965 121.863,91.965 121.863,92.859C121.863,93.715 121.488,94.062 120.672,94.062C119.805,94.062 119.551,93.746 119.52,93.164L118.152,93.164C118.152,94.387 118.754,95.301 120.641,95.301C122.461,95.301 123.219,94.562 123.219,92.812C123.219,91.297 122.383,90.941 121.508,90.805C120.355,90.629 119.598,90.707 119.598,89.754C119.598,89.035 119.902,88.797 120.652,88.797C121.309,88.797 121.645,88.984 121.684,89.625Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M135.348,87.648L130.91,87.648L130.91,95.23L132.258,95.23L132.258,92.004L134.875,92.004L134.875,90.773L132.258,90.773L132.258,88.887L135.348,88.887L135.348,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M140.82,95.289C142.621,95.289 143.406,94.594 143.406,93.027L143.406,89.82C143.406,88.219 142.648,87.559 140.82,87.559C138.988,87.559 138.289,88.219 138.289,89.82L138.289,93.027C138.289,94.594 139.02,95.289 140.82,95.289ZM140.82,94.09C139.836,94.09 139.648,93.676 139.648,92.961L139.648,89.891C139.648,89.199 139.836,88.758 140.82,88.758C141.871,88.758 142.051,89.199 142.051,89.891L142.051,92.961C142.051,93.676 141.805,94.09 140.82,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M151.703,95.223L150.039,92.34C150.957,92.043 151.348,91.434 151.348,90.312L151.348,89.918C151.348,88.316 150.492,87.648 148.664,87.648L146.754,87.648L146.754,95.223L148.113,95.223L148.113,92.555L148.613,92.555L150.121,95.223L151.703,95.223ZM148.102,91.305L148.102,88.895L148.684,88.895C149.734,88.895 149.922,89.27 149.922,89.988L149.922,90.242C149.922,90.961 149.648,91.305 148.664,91.305L148.102,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M161.707,87.656L160.359,87.656L158.262,95.223L159.66,95.223L160.055,93.883L161.992,93.883L162.375,95.223L163.773,95.223L161.707,87.656ZM160.387,92.555L161.016,89.754L161.648,92.555L160.387,92.555Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M173.254,92.145L174.543,92.145L174.543,92.879C174.543,93.629 174.195,94.043 173.215,94.043C172.23,94.043 172.043,93.629 172.043,92.91L172.043,89.938C172.043,89.25 172.23,88.809 173.215,88.809C174.266,88.809 174.441,89.16 174.441,89.871L175.801,89.871C175.801,88.246 175.043,87.605 173.215,87.605C171.383,87.605 170.684,88.324 170.684,89.871L170.684,92.91C170.684,94.543 171.414,95.262 173.215,95.262C175.012,95.262 175.801,94.543 175.801,92.879L175.801,90.914L173.254,90.914L173.254,92.145Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M184.02,95.223L182.355,92.34C183.27,92.043 183.664,91.434 183.664,90.312L183.664,89.918C183.664,88.316 182.809,87.648 180.98,87.648L179.07,87.648L179.07,95.223L180.426,95.223L180.426,92.555L180.93,92.555L182.434,95.223L184.02,95.223ZM180.418,91.305L180.418,88.895L181,88.895C182.051,88.895 182.238,89.27 182.238,89.988L182.238,90.242C182.238,90.961 181.961,91.305 180.98,91.305L180.418,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M186.965,87.648L186.965,95.23L191.461,95.23L191.461,93.992L188.32,93.992L188.32,91.996L190.879,91.996L190.879,90.773L188.32,90.773L188.32,88.828L191.32,88.828L191.32,87.648L186.965,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M194.562,87.648L194.562,95.23L199.059,95.23L199.059,93.992L195.918,93.992L195.918,91.996L198.477,91.996L198.477,90.773L195.918,90.773L195.918,88.828L198.922,88.828L198.922,87.648L194.562,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M206.922,87.656L205.574,87.656L205.574,89.445L205.723,92.645L203.496,87.656L202.148,87.656L202.148,95.223L203.496,95.223L203.496,93.293L203.379,90.422L205.602,95.223L206.922,95.223L206.922,87.656Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M210.34,87.648L210.34,95.23L214.836,95.23L214.836,93.992L211.695,93.992L211.695,91.996L214.254,91.996L214.254,90.773L211.695,90.773L211.695,88.828L214.695,88.828L214.695,87.648L210.34,87.648Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M222.887,95.223L221.223,92.34C222.137,92.043 222.531,91.434 222.531,90.312L222.531,89.918C222.531,88.316 221.676,87.648 219.848,87.648L217.938,87.648L217.938,95.223L219.293,95.223L219.293,92.555L219.797,92.555L221.301,95.223L222.887,95.223ZM219.285,91.305L219.285,88.895L219.867,88.895C220.918,88.895 221.105,89.27 221.105,89.988L221.105,90.242C221.105,90.961 220.828,91.305 219.844,91.305L219.285,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M233.93,87.676L229.445,87.676L229.445,88.887L231.02,88.887L231.02,95.223L232.367,95.223L232.367,88.887L233.93,88.887L233.93,87.676Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M238.922,95.289C240.723,95.289 241.508,94.594 241.508,93.027L241.508,89.82C241.508,88.219 240.75,87.559 238.922,87.559C237.094,87.559 236.395,88.219 236.395,89.82L236.395,93.027C236.395,94.594 237.121,95.289 238.922,95.289ZM238.922,94.09C237.938,94.09 237.75,93.676 237.75,92.961L237.75,89.891C237.75,89.199 237.938,88.758 238.922,88.758C239.973,88.758 240.152,89.199 240.152,89.891L240.152,92.961C240.152,93.676 239.906,94.09 238.922,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M247.867,93.43L249.375,90.383L249.215,92.547L249.215,95.223L250.574,95.223L250.574,87.648L249.266,87.648L247.711,91L246.164,87.648L244.859,87.648L244.859,95.223L246.215,95.223L246.215,92.547L246.059,90.383L247.562,93.43L247.867,93.43Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M256.41,95.289C258.211,95.289 259,94.594 259,93.027L259,89.82C259,88.219 258.242,87.559 256.41,87.559C254.582,87.559 253.883,88.219 253.883,89.82L253.883,93.027C253.883,94.594 254.609,95.289 256.41,95.289ZM256.41,94.09C255.426,94.09 255.238,93.676 255.238,92.961L255.238,89.891C255.238,89.199 255.426,88.758 256.41,88.758C257.465,88.758 257.64,89.199 257.64,89.891L257.64,92.961C257.64,93.676 257.394,94.09 256.41,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M267.297,95.223L265.633,92.34C266.547,92.043 266.941,91.434 266.941,90.312L266.941,89.918C266.941,88.316 266.086,87.648 264.254,87.648L262.348,87.648L262.348,95.223L263.703,95.223L263.703,92.555L264.207,92.555L265.711,95.223L267.297,95.223ZM263.695,91.305L263.695,88.895L264.273,88.895C265.328,88.895 265.516,89.27 265.516,89.988L265.516,90.242C265.516,90.961 265.238,91.305 264.254,91.305L263.695,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M275.188,95.223L273.527,92.34C274.441,92.043 274.836,91.434 274.836,90.312L274.836,89.918C274.836,88.316 273.977,87.648 272.148,87.648L270.238,87.648L270.238,95.223L271.598,95.223L271.598,92.555L272.098,92.555L273.605,95.223L275.188,95.223ZM271.586,91.305L271.586,88.895L272.168,88.895C273.223,88.895 273.406,89.27 273.406,89.988L273.406,90.242C273.406,90.961 273.133,91.305 272.148,91.305L271.586,91.305Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M280.555,95.289C282.355,95.289 283.141,94.594 283.141,93.027L283.141,89.82C283.141,88.219 282.383,87.559 280.555,87.559C278.723,87.559 278.023,88.219 278.023,89.82L278.023,93.027C278.023,94.594 278.754,95.289 280.555,95.289ZM280.555,94.09C279.57,94.09 279.383,93.676 279.383,92.961L279.383,89.891C279.383,89.199 279.57,88.758 280.555,88.758C281.605,88.758 281.785,89.199 281.785,89.891L281.785,92.961C281.785,93.676 281.539,94.09 280.555,94.09Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M290.141,91.141L291.25,95.23L292.688,95.23L294.586,87.648L293.188,87.648L292.441,90.262L292,93.352L290.66,87.895L289.617,87.895L288.379,93.352L287.836,90.262L287.09,87.648L285.691,87.648L287.699,95.23L289.125,95.23L290.141,91.141Z" style="fill:#000000;fill-rule:nonzero;"></path>
|
||||
<path d="M90.383,76.133C90.336,76.137 90.293,76.141 90.25,76.141L80.816,76.141C80.773,76.141 80.73,76.137 80.684,76.133C80.641,76.129 80.598,76.121 80.555,76.113C80.508,76.105 80.465,76.094 80.426,76.082C80.383,76.066 80.34,76.055 80.297,76.035C80.258,76.02 80.219,76 80.18,75.98C80.141,75.957 80.102,75.934 80.066,75.91C80.027,75.887 79.992,75.859 79.957,75.832C79.922,75.805 79.891,75.773 79.859,75.742C79.828,75.711 79.797,75.68 79.77,75.645C79.742,75.609 79.715,75.574 79.691,75.535C79.668,75.5 79.645,75.461 79.621,75.422C79.602,75.383 79.582,75.344 79.566,75.301C79.547,75.262 79.535,75.219 79.52,75.176C79.508,75.137 79.496,75.094 79.488,75.047C79.48,75.004 79.473,74.961 79.469,74.918C79.465,74.871 79.461,74.828 79.461,74.785L79.461,17.875C79.461,17.828 79.465,17.785 79.469,17.742C79.473,17.695 79.48,17.652 79.488,17.609C79.496,17.566 79.508,17.523 79.52,17.48C79.535,17.438 79.547,17.395 79.566,17.355C79.582,17.312 79.602,17.273 79.621,17.234C79.645,17.195 79.668,17.156 79.691,17.121C79.715,17.082 79.742,17.047 79.77,17.012C79.797,16.98 79.828,16.945 79.859,16.914C79.891,16.883 79.922,16.855 79.957,16.824C79.992,16.797 80.027,16.77 80.066,16.746C80.102,16.723 80.141,16.699 80.18,16.68C80.219,16.656 80.258,16.637 80.297,16.621C80.34,16.605 80.383,16.59 80.426,16.578C80.465,16.562 80.508,16.555 80.555,16.543C80.598,16.535 80.641,16.527 80.684,16.523C80.73,16.52 80.773,16.52 80.816,16.52L90.25,16.52C90.293,16.52 90.336,16.52 90.383,16.523C90.426,16.527 90.469,16.535 90.512,16.543C90.555,16.555 90.598,16.562 90.641,16.578C90.684,16.59 90.727,16.605 90.766,16.621C90.809,16.637 90.848,16.656 90.887,16.68C90.926,16.699 90.965,16.723 91,16.746C91.039,16.77 91.074,16.797 91.109,16.824C91.141,16.855 91.176,16.883 91.207,16.914C91.238,16.945 91.27,16.98 91.297,17.012C91.324,17.047 91.352,17.082 91.375,17.121C91.398,17.156 91.422,17.195 91.441,17.234C91.465,17.273 91.484,17.312 91.5,17.355C91.516,17.395 91.531,17.438 91.547,17.48C91.559,17.523 91.57,17.566 91.578,17.609C91.586,17.652 91.594,17.695 91.598,17.742C91.602,17.785 91.602,17.828 91.602,17.875L91.602,44.699L129.363,16.785C129.477,16.695 129.604,16.633 129.742,16.586C129.882,16.539 130.022,16.52 130.168,16.52L143.746,16.52C143.852,16.52 143.953,16.531 144.059,16.555C144.16,16.578 144.258,16.613 144.355,16.664C144.449,16.711 144.535,16.77 144.617,16.836C144.699,16.906 144.77,16.98 144.832,17.066C144.859,17.102 144.883,17.137 144.906,17.176C144.93,17.215 144.949,17.254 144.969,17.293C144.988,17.332 145.004,17.375 145.02,17.418C145.035,17.457 145.047,17.5 145.059,17.543C145.07,17.586 145.078,17.629 145.086,17.676C145.09,17.719 145.098,17.762 145.098,17.805C145.102,17.852 145.102,17.895 145.098,17.941C145.098,17.984 145.09,18.027 145.086,18.07C145.078,18.117 145.07,18.16 145.059,18.203C145.047,18.246 145.035,18.289 145.02,18.328C145.004,18.371 144.988,18.414 144.969,18.453C144.949,18.492 144.93,18.531 144.906,18.57C144.883,18.609 144.859,18.645 144.832,18.68C144.805,18.715 144.777,18.75 144.75,18.781C144.719,18.816 144.688,18.848 144.656,18.879C144.621,18.906 144.586,18.934 144.551,18.961L91.602,58.23L91.602,74.785C91.602,74.828 91.602,74.871 91.598,74.918C91.594,74.961 91.586,75.004 91.578,75.047C91.57,75.094 91.559,75.137 91.547,75.176C91.531,75.219 91.516,75.262 91.5,75.301C91.484,75.344 91.465,75.383 91.441,75.422C91.422,75.461 91.398,75.5 91.375,75.535C91.352,75.574 91.324,75.609 91.297,75.645C91.27,75.68 91.238,75.711 91.207,75.742C91.176,75.773 91.141,75.805 91.109,75.832C91.074,75.859 91.039,75.887 91,75.91C90.965,75.934 90.926,75.957 90.887,75.98C90.848,76 90.809,76.02 90.766,76.035C90.727,76.055 90.684,76.066 90.641,76.082C90.598,76.094 90.555,76.105 90.512,76.113C90.469,76.121 90.426,76.129 90.383,76.133ZM88.93,57.234C88.906,57.34 88.895,57.441 88.895,57.547L88.895,73.43L82.172,73.43L82.172,19.227L88.895,19.227L88.895,47.387C88.895,47.531 88.914,47.672 88.961,47.809C89.008,47.949 89.074,48.074 89.16,48.191C89.211,48.262 89.27,48.328 89.336,48.387C89.402,48.449 89.473,48.5 89.551,48.547C89.625,48.594 89.707,48.629 89.789,48.66C89.875,48.691 89.961,48.711 90.047,48.727C90.137,48.738 90.223,48.742 90.312,48.738C90.402,48.734 90.488,48.723 90.574,48.699C90.66,48.68 90.746,48.648 90.824,48.613C90.906,48.574 90.98,48.527 91.055,48.477L130.613,19.227L139.645,19.227L89.441,56.461C89.355,56.523 89.281,56.594 89.211,56.676C89.145,56.758 89.086,56.844 89.039,56.938C88.992,57.035 88.953,57.133 88.93,57.234Z" style="fill:#000000;"></path>
|
||||
<path d="M148.246,74.535C148.262,74.617 148.27,74.699 148.27,74.785C148.27,74.828 148.27,74.871 148.262,74.918C148.258,74.961 148.254,75.004 148.246,75.047C148.234,75.094 148.227,75.137 148.211,75.176C148.199,75.219 148.184,75.262 148.168,75.301C148.148,75.344 148.133,75.383 148.109,75.422C148.09,75.461 148.066,75.5 148.043,75.535C148.016,75.574 147.992,75.609 147.961,75.645C147.934,75.68 147.906,75.711 147.875,75.742C147.844,75.773 147.809,75.805 147.773,75.832C147.742,75.859 147.707,75.887 147.668,75.91C147.633,75.934 147.594,75.957 147.555,75.98C147.516,76 147.477,76.02 147.434,76.035C147.395,76.055 147.352,76.066 147.309,76.082C147.266,76.094 147.223,76.105 147.18,76.113C147.137,76.121 147.094,76.129 147.047,76.133C147.004,76.137 146.961,76.141 146.914,76.141L134.719,76.141C134.625,76.141 134.531,76.129 134.441,76.109C134.348,76.09 134.258,76.062 134.172,76.023C134.086,75.984 134.004,75.938 133.926,75.883C133.852,75.828 133.781,75.766 133.719,75.695L110.465,50.086C110.438,50.055 110.41,50.02 110.383,49.988C110.355,49.953 110.332,49.914 110.309,49.879C110.285,49.84 110.266,49.801 110.246,49.762C110.227,49.719 110.211,49.68 110.195,49.637C110.18,49.598 110.168,49.555 110.156,49.512C110.145,49.469 110.137,49.426 110.129,49.379C110.121,49.336 110.117,49.293 110.113,49.246L110.113,49.113C110.117,49.07 110.121,49.027 110.125,48.984C110.133,48.938 110.141,48.895 110.152,48.852C110.164,48.809 110.176,48.766 110.191,48.723C110.203,48.684 110.223,48.641 110.238,48.602C110.258,48.562 110.281,48.523 110.301,48.484C110.324,48.445 110.348,48.41 110.375,48.371C110.402,48.336 110.43,48.301 110.461,48.27C110.488,48.238 110.52,48.203 110.551,48.176C110.586,48.145 110.621,48.117 110.656,48.09L117.809,42.723C117.844,42.699 117.879,42.676 117.914,42.652C117.949,42.633 117.984,42.613 118.023,42.594C118.059,42.574 118.098,42.559 118.137,42.543C118.176,42.527 118.215,42.516 118.254,42.504C118.293,42.492 118.336,42.484 118.375,42.477C118.418,42.469 118.457,42.461 118.5,42.457C118.543,42.457 118.582,42.453 118.625,42.453C118.668,42.453 118.707,42.457 118.75,42.461C118.789,42.465 118.832,42.469 118.871,42.477C118.914,42.484 118.953,42.492 118.992,42.504C119.035,42.516 119.074,42.531 119.113,42.547C119.152,42.559 119.188,42.578 119.227,42.594C119.266,42.613 119.301,42.633 119.336,42.656C119.371,42.68 119.406,42.703 119.438,42.727C119.473,42.75 119.504,42.777 119.535,42.805C119.566,42.836 119.594,42.863 119.621,42.895L147.918,73.871C147.973,73.934 148.023,74 148.066,74.07C148.109,74.141 148.148,74.215 148.18,74.293C148.211,74.371 148.23,74.453 148.246,74.535ZM135.32,73.43L113.473,49.363L118.453,45.629L143.844,73.43L135.32,73.43Z" style="fill:#000000;"></path>
|
||||
<path d="M171.984,16.52C171.938,16.52 171.895,16.52 171.852,16.523C171.805,16.527 171.762,16.535 171.719,16.543C171.676,16.555 171.633,16.562 171.59,16.578C171.547,16.59 171.504,16.605 171.465,16.621C171.426,16.637 171.383,16.656 171.344,16.68C171.305,16.699 171.27,16.723 171.23,16.746C171.195,16.77 171.156,16.797 171.125,16.824C171.09,16.855 171.055,16.883 171.023,16.914C170.992,16.945 170.965,16.98 170.938,17.012C170.906,17.047 170.883,17.082 170.855,17.121C170.832,17.156 170.809,17.195 170.789,17.234C170.766,17.273 170.75,17.312 170.73,17.355C170.715,17.395 170.699,17.438 170.688,17.48C170.676,17.523 170.664,17.566 170.652,17.609C170.645,17.652 170.641,17.695 170.637,17.742C170.629,17.785 170.629,17.828 170.629,17.875L170.629,74.785C170.629,74.828 170.629,74.871 170.637,74.918C170.641,74.961 170.645,75.004 170.652,75.047C170.664,75.094 170.676,75.137 170.688,75.176C170.699,75.219 170.715,75.262 170.73,75.301C170.75,75.344 170.766,75.383 170.789,75.422C170.809,75.461 170.832,75.5 170.855,75.535C170.883,75.574 170.906,75.609 170.938,75.645C170.965,75.68 170.992,75.711 171.023,75.742C171.055,75.773 171.09,75.805 171.125,75.832C171.156,75.859 171.195,75.887 171.23,75.91C171.27,75.934 171.305,75.957 171.344,75.98C171.383,76 171.426,76.02 171.465,76.035C171.504,76.055 171.547,76.066 171.59,76.082C171.633,76.094 171.676,76.105 171.719,76.113C171.762,76.121 171.805,76.129 171.852,76.133C171.895,76.137 171.938,76.141 171.984,76.141L212.391,76.141C212.434,76.141 212.48,76.137 212.523,76.133C212.566,76.129 212.609,76.121 212.656,76.113C212.699,76.105 212.742,76.094 212.785,76.082C212.828,76.066 212.867,76.055 212.91,76.035C212.949,76.02 212.988,76 213.027,75.98C213.066,75.957 213.105,75.934 213.145,75.91C213.18,75.887 213.215,75.859 213.25,75.832C213.285,75.805 213.316,75.773 213.348,75.742C213.379,75.711 213.41,75.68 213.438,75.645C213.465,75.609 213.492,75.574 213.516,75.535C213.543,75.5 213.566,75.461 213.586,75.422C213.605,75.383 213.625,75.344 213.641,75.301C213.66,75.262 213.672,75.219 213.688,75.176C213.699,75.137 213.711,75.094 213.719,75.047C213.727,75.004 213.734,74.961 213.738,74.918C213.742,74.871 213.746,74.828 213.746,74.785L213.746,66.328C213.746,66.285 213.742,66.238 213.738,66.195C213.734,66.152 213.727,66.109 213.719,66.062C213.711,66.02 213.699,65.977 213.688,65.934C213.672,65.895 213.66,65.852 213.641,65.809C213.625,65.77 213.605,65.73 213.586,65.691C213.566,65.652 213.543,65.613 213.516,65.574C213.492,65.539 213.465,65.504 213.438,65.469C213.41,65.434 213.379,65.402 213.348,65.372C213.316,65.34 213.285,65.309 213.25,65.281C213.215,65.254 213.18,65.227 213.145,65.204C213.105,65.177 213.066,65.156 213.027,65.134C212.988,65.113 212.949,65.094 212.91,65.079C212.867,65.059 212.828,65.047 212.785,65.031C212.742,65.02 212.699,65.009 212.656,65C212.609,64.992 212.566,64.984 212.523,64.981C212.48,64.977 212.434,64.973 212.391,64.973L182.77,64.973L182.77,17.875C182.77,17.828 182.766,17.785 182.762,17.742C182.758,17.695 182.754,17.652 182.742,17.609C182.734,17.566 182.723,17.523 182.711,17.48C182.699,17.438 182.684,17.395 182.668,17.355C182.648,17.312 182.629,17.273 182.609,17.234C182.59,17.195 182.566,17.156 182.543,17.121C182.516,17.082 182.488,17.047 182.461,17.012C182.434,16.98 182.402,16.945 182.371,16.914C182.34,16.883 182.309,16.855 182.273,16.824C182.238,16.797 182.203,16.77 182.168,16.746C182.129,16.723 182.094,16.699 182.055,16.68C182.016,16.656 181.973,16.637 181.934,16.621C181.891,16.605 181.852,16.59 181.809,16.578C181.766,16.562 181.723,16.555 181.68,16.543C181.637,16.535 181.59,16.527 181.547,16.523C181.504,16.52 181.457,16.52 181.414,16.52L171.984,16.52ZM173.34,19.227L173.34,73.43L211.035,73.43L211.035,67.684L181.414,67.684C181.371,67.684 181.324,67.68 181.281,67.676C181.238,67.672 181.195,67.668 181.148,67.656C181.105,67.648 181.062,67.637 181.02,67.625C180.977,67.613 180.938,67.598 180.895,67.582C180.855,67.562 180.816,67.543 180.777,67.523C180.738,67.504 180.699,67.48 180.66,67.457C180.625,67.43 180.59,67.406 180.555,67.375C180.52,67.348 180.488,67.316 180.457,67.285C180.426,67.254 180.395,67.223 180.367,67.188C180.34,67.152 180.312,67.117 180.289,67.082C180.262,67.043 180.238,67.008 180.219,66.969C180.199,66.93 180.18,66.887 180.164,66.848C180.145,66.805 180.129,66.766 180.117,66.723C180.105,66.68 180.094,66.637 180.086,66.594C180.078,66.551 180.07,66.504 180.066,66.461C180.062,66.418 180.059,66.375 180.059,66.328L180.059,19.227L173.34,19.227Z" style="fill:#000000;"></path>
|
||||
<path d="M294.578,66.195C294.582,66.238 294.586,66.285 294.586,66.328L294.586,74.785C294.586,74.828 294.582,74.871 294.578,74.918C294.574,74.961 294.57,75.004 294.559,75.047C294.551,75.094 294.539,75.137 294.527,75.176C294.516,75.219 294.5,75.262 294.484,75.301C294.465,75.344 294.445,75.383 294.426,75.422C294.406,75.461 294.383,75.5 294.359,75.535C294.332,75.574 294.305,75.609 294.277,75.645C294.25,75.68 294.219,75.711 294.188,75.742C294.156,75.773 294.125,75.805 294.09,75.832C294.055,75.859 294.02,75.887 293.984,75.91C293.945,75.934 293.91,75.957 293.871,75.98C293.832,76 293.789,76.02 293.75,76.035C293.707,76.055 293.668,76.066 293.625,76.082C293.582,76.094 293.539,76.105 293.496,76.113C293.453,76.121 293.406,76.129 293.363,76.133C293.32,76.137 293.273,76.141 293.23,76.141L237.457,76.141C237.414,76.141 237.371,76.137 237.324,76.133C237.281,76.129 237.238,76.121 237.195,76.113C237.148,76.105 237.105,76.094 237.062,76.082C237.023,76.066 236.98,76.055 236.941,76.035C236.898,76.02 236.859,76 236.82,75.98C236.781,75.957 236.742,75.934 236.707,75.91C236.668,75.887 236.633,75.859 236.598,75.832C236.562,75.805 236.531,75.773 236.5,75.742C236.469,75.711 236.438,75.68 236.41,75.645C236.383,75.609 236.355,75.574 236.332,75.535C236.305,75.5 236.285,75.461 236.262,75.422C236.242,75.383 236.223,75.344 236.207,75.301C236.188,75.262 236.176,75.219 236.16,75.176C236.148,75.137 236.137,75.094 236.129,75.047C236.121,75.004 236.113,74.961 236.109,74.918C236.105,74.871 236.102,74.828 236.102,74.785L236.102,66.328C236.102,66.23 236.113,66.137 236.133,66.043C236.156,65.945 236.184,65.855 236.227,65.766C236.266,65.68 236.312,65.594 236.371,65.52C236.43,65.441 236.496,65.372 236.57,65.305L292.344,16.852C292.402,16.797 292.469,16.75 292.539,16.707C292.609,16.668 292.68,16.633 292.758,16.605C292.832,16.574 292.91,16.555 292.988,16.539C293.07,16.523 293.148,16.52 293.23,16.52C293.273,16.52 293.32,16.52 293.363,16.523C293.406,16.527 293.453,16.535 293.496,16.543C293.539,16.555 293.582,16.562 293.625,16.578C293.668,16.59 293.707,16.605 293.75,16.621C293.789,16.637 293.832,16.656 293.871,16.68C293.91,16.699 293.945,16.723 293.984,16.746C294.02,16.77 294.055,16.797 294.09,16.824C294.125,16.855 294.156,16.883 294.188,16.914C294.219,16.945 294.25,16.98 294.277,17.012C294.305,17.047 294.332,17.082 294.359,17.121C294.383,17.156 294.406,17.195 294.426,17.234C294.445,17.273 294.465,17.312 294.484,17.355C294.5,17.395 294.516,17.438 294.527,17.48C294.539,17.523 294.551,17.566 294.559,17.609C294.57,17.652 294.574,17.695 294.578,17.742C294.582,17.785 294.586,17.828 294.586,17.875L294.586,28.766C294.586,28.863 294.574,28.961 294.555,29.055C294.535,29.148 294.504,29.238 294.465,29.328C294.426,29.418 294.375,29.5 294.316,29.578C294.262,29.652 294.195,29.727 294.121,29.789L253.906,64.899L293.234,64.973C293.277,64.973 293.324,64.977 293.367,64.981C293.41,64.984 293.453,64.992 293.496,65C293.543,65.009 293.582,65.02 293.625,65.031C293.668,65.047 293.711,65.062 293.75,65.079C293.793,65.094 293.832,65.113 293.871,65.134C293.91,65.156 293.949,65.18 293.984,65.204C294.023,65.227 294.059,65.254 294.09,65.281C294.125,65.309 294.16,65.34 294.191,65.372C294.223,65.402 294.25,65.439 294.277,65.469C294.309,65.504 294.332,65.539 294.359,65.578C294.383,65.613 294.406,65.652 294.426,65.691C294.445,65.73 294.465,65.77 294.484,65.812C294.5,65.852 294.516,65.895 294.527,65.938C294.539,65.977 294.551,66.02 294.559,66.066C294.57,66.109 294.574,66.152 294.578,66.195ZM250.301,67.602L291.875,67.68L291.875,73.43L238.812,73.43L238.812,66.945L291.875,20.844L291.875,28.152L249.414,65.227C249.34,65.289 249.273,65.359 249.219,65.439C249.16,65.516 249.109,65.598 249.07,65.688C249.031,65.773 249,65.863 248.98,65.957C248.957,66.055 248.949,66.148 248.949,66.246C248.949,66.289 248.949,66.332 248.953,66.379C248.961,66.422 248.965,66.465 248.973,66.508C248.984,66.555 248.992,66.598 249.008,66.637C249.02,66.68 249.035,66.723 249.051,66.762C249.066,66.805 249.086,66.844 249.105,66.883C249.129,66.922 249.152,66.961 249.176,67C249.199,67.035 249.227,67.07 249.254,67.105C249.281,67.141 249.312,67.172 249.344,67.203C249.375,67.234 249.406,67.266 249.441,67.293C249.477,67.32 249.512,67.348 249.551,67.371C249.586,67.398 249.625,67.422 249.664,67.441C249.703,67.461 249.742,67.48 249.781,67.5C249.824,67.516 249.867,67.531 249.906,67.543C249.949,67.555 249.992,67.566 250.035,67.574C250.082,67.586 250.125,67.59 250.168,67.594C250.211,67.602 250.258,67.602 250.301,67.602Z" style="fill:#000000;"></path>
|
||||
<path d="M238.059,16.523C238.102,16.52 238.145,16.52 238.191,16.52L281.281,16.52C281.383,16.52 281.484,16.531 281.582,16.551C281.684,16.574 281.781,16.609 281.871,16.656C281.965,16.699 282.051,16.754 282.133,16.82C282.211,16.883 282.281,16.957 282.348,17.039C282.375,17.074 282.398,17.109 282.422,17.145C282.445,17.184 282.469,17.223 282.488,17.262C282.508,17.301 282.527,17.34 282.543,17.383C282.559,17.426 282.574,17.465 282.586,17.508C282.598,17.551 282.605,17.594 282.613,17.641C282.621,17.684 282.629,17.727 282.629,17.77C282.633,17.816 282.637,17.859 282.633,17.902C282.633,17.949 282.629,17.992 282.625,18.035C282.621,18.082 282.613,18.125 282.602,18.168C282.594,18.211 282.582,18.254 282.566,18.297C282.555,18.336 282.539,18.379 282.52,18.418C282.5,18.461 282.48,18.5 282.461,18.539C282.438,18.578 282.414,18.613 282.387,18.652C282.363,18.688 282.336,18.723 282.309,18.758C282.277,18.789 282.246,18.82 282.215,18.852C282.184,18.883 282.148,18.914 282.117,18.941L271.223,27.477C271.102,27.57 270.969,27.641 270.828,27.691C270.684,27.738 270.535,27.766 270.387,27.766L238.191,27.766C238.145,27.766 238.102,27.762 238.059,27.758C238.012,27.754 237.969,27.746 237.926,27.738C237.883,27.73 237.84,27.719 237.797,27.707C237.754,27.695 237.711,27.68 237.672,27.66C237.629,27.645 237.59,27.625 237.551,27.605C237.512,27.582 237.473,27.562 237.438,27.535C237.398,27.512 237.363,27.484 237.328,27.457C237.297,27.43 237.262,27.398 237.23,27.367C237.199,27.336 237.172,27.305 237.141,27.27C237.113,27.234 237.09,27.199 237.062,27.164C237.039,27.125 237.016,27.086 236.996,27.047C236.973,27.008 236.953,26.969 236.938,26.93C236.922,26.887 236.906,26.844 236.895,26.805C236.879,26.762 236.871,26.719 236.859,26.676C236.852,26.629 236.844,26.586 236.84,26.543C236.836,26.5 236.836,26.453 236.836,26.41L236.836,17.875C236.836,17.828 236.836,17.785 236.84,17.742C236.844,17.695 236.852,17.652 236.859,17.609C236.871,17.566 236.879,17.523 236.895,17.48C236.906,17.438 236.922,17.395 236.938,17.355C236.953,17.312 236.973,17.273 236.996,17.234C237.016,17.195 237.039,17.156 237.062,17.121C237.09,17.082 237.113,17.047 237.141,17.012C237.172,16.98 237.199,16.945 237.23,16.914C237.262,16.883 237.297,16.855 237.328,16.824C237.363,16.797 237.398,16.77 237.438,16.746C237.473,16.723 237.512,16.699 237.551,16.68C237.59,16.656 237.629,16.637 237.672,16.621C237.711,16.605 237.754,16.59 237.797,16.578C237.84,16.562 237.883,16.555 237.926,16.543C237.969,16.535 238.012,16.527 238.059,16.523ZM277.352,19.227L269.918,25.055L239.543,25.055L239.543,19.227L277.352,19.227Z" style="fill:#000000;"></path>
|
||||
<path d="M24.406,28.266L16.988,0.547C16.988,0.328 16.77,0.109 16.441,0.109L15.023,0C14.586,0 14.258,0.328 14.367,0.762L19.059,27.5C19.059,27.719 19.277,27.828 19.496,27.938L21.57,28.59C21.789,28.59 21.898,28.699 22.008,28.918C22.66,28.484 23.426,28.266 24.188,28.266L24.406,28.266Z" style="fill:#000000;"></path>
|
||||
<path d="M26.688,32.547C26.695,32.465 26.699,32.383 26.699,32.301C26.699,32.219 26.695,32.137 26.688,32.055C26.68,31.973 26.668,31.895 26.652,31.812C26.633,31.73 26.613,31.652 26.59,31.574C26.566,31.496 26.539,31.418 26.508,31.34C26.477,31.266 26.441,31.191 26.402,31.117C26.363,31.047 26.32,30.977 26.277,30.906C26.23,30.84 26.18,30.773 26.129,30.711C26.078,30.648 26.023,30.586 25.965,30.527C25.906,30.469 25.844,30.414 25.781,30.363C25.719,30.309 25.652,30.262 25.582,30.215C25.516,30.168 25.445,30.125 25.371,30.09C25.301,30.051 25.227,30.016 25.148,29.984C25.074,29.953 24.996,29.926 24.918,29.898C24.84,29.875 24.758,29.855 24.68,29.84C24.598,29.824 24.516,29.812 24.434,29.805C24.352,29.797 24.27,29.793 24.188,29.793C24.105,29.793 24.023,29.797 23.945,29.805C23.859,29.812 23.781,29.824 23.699,29.84C23.617,29.855 23.539,29.875 23.461,29.898C23.383,29.926 23.305,29.953 23.23,29.984C23.152,30.016 23.078,30.051 23.008,30.09C22.934,30.125 22.863,30.168 22.793,30.215C22.727,30.262 22.66,30.309 22.598,30.363C22.535,30.414 22.473,30.469 22.414,30.527C22.355,30.586 22.301,30.648 22.25,30.711C22.195,30.773 22.148,30.84 22.102,30.906C22.055,30.977 22.016,31.047 21.977,31.117C21.938,31.191 21.902,31.266 21.871,31.34C21.84,31.418 21.812,31.496 21.789,31.574C21.762,31.652 21.742,31.73 21.727,31.812C21.711,31.895 21.699,31.973 21.691,32.055C21.684,32.137 21.68,32.219 21.68,32.301C21.68,32.383 21.684,32.465 21.691,32.547C21.699,32.629 21.711,32.711 21.727,32.793C21.742,32.871 21.762,32.953 21.789,33.031C21.812,33.109 21.84,33.188 21.871,33.262C21.902,33.34 21.938,33.414 21.977,33.484C22.016,33.559 22.055,33.629 22.102,33.695C22.148,33.766 22.195,33.832 22.25,33.895C22.301,33.957 22.355,34.02 22.414,34.078C22.473,34.137 22.535,34.191 22.598,34.242C22.66,34.293 22.727,34.344 22.793,34.391C22.863,34.434 22.934,34.477 23.008,34.516C23.078,34.555 23.152,34.59 23.23,34.621C23.305,34.652 23.383,34.68 23.461,34.703C23.539,34.727 23.617,34.746 23.699,34.766C23.781,34.781 23.859,34.793 23.945,34.801C24.023,34.809 24.105,34.812 24.188,34.812C24.27,34.812 24.352,34.809 24.434,34.801C24.516,34.793 24.598,34.781 24.68,34.766C24.758,34.746 24.84,34.727 24.918,34.703C24.996,34.68 25.074,34.652 25.148,34.621C25.227,34.59 25.301,34.555 25.371,34.516C25.445,34.477 25.516,34.434 25.582,34.391C25.652,34.344 25.719,34.293 25.781,34.242C25.844,34.191 25.906,34.137 25.965,34.078C26.023,34.02 26.078,33.957 26.129,33.895C26.18,33.832 26.23,33.766 26.277,33.695C26.32,33.629 26.363,33.559 26.402,33.484C26.441,33.414 26.477,33.34 26.508,33.262C26.539,33.188 26.566,33.109 26.59,33.031C26.613,32.953 26.633,32.871 26.652,32.793C26.668,32.711 26.68,32.629 26.688,32.547Z" style="fill:#000000;"></path>
|
||||
<path d="M55.945,41.688L56.711,40.488C56.926,40.16 56.816,39.723 56.383,39.504L30.957,30.23L30.738,30.23C30.52,30.23 30.41,30.336 30.301,30.445L28.664,31.977C28.555,32.082 28.336,32.191 28.227,32.191L28.117,32.191L28.117,32.41C28.117,33.176 27.898,33.938 27.465,34.594L55.289,42.016L55.398,42.016C55.617,42.016 55.836,41.906 55.945,41.688Z" style="fill:#000000;"></path>
|
||||
<path d="M1.707,56.527L21.68,39.941L22.551,39.176C22.66,39.066 22.77,38.742 22.66,38.523L22.117,36.34C22.117,36.121 22.117,35.902 22.223,35.793C22.008,35.684 21.898,35.574 21.68,35.465C21.133,35.141 20.805,34.594 20.477,34.047L0.18,54.348C-0.038,54.562 -0.038,54.891 0.07,55.109L0.727,56.309C0.835,56.527 1.055,56.637 1.273,56.637C1.492,56.637 1.598,56.637 1.707,56.527Z" style="fill:#000000;"></path>
|
||||
<path d="M25.824,35.902L28.008,98.215L20.371,98.215L22.332,41.25L23.535,40.27C24.188,39.723 24.406,38.957 24.188,38.195L23.754,36.449L23.973,36.23L24.188,36.23C24.844,36.23 25.391,36.121 25.824,35.902Z" style="fill:#000000;"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 32 KiB |
BIN
public/logo-blue.png
Normal file
BIN
public/logo-blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/logo-white.png
Normal file
BIN
public/logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
41
scripts/debug-cms.ts
Normal file
41
scripts/debug-cms.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: { slug: { equals: 'n2xsy' } },
|
||||
locale: 'en' as any,
|
||||
});
|
||||
const doc = result.docs[0];
|
||||
if (!doc) { console.log('No doc found'); process.exit(0); }
|
||||
console.log('--- doc.title:', doc.title);
|
||||
|
||||
if (doc.content?.root?.children) {
|
||||
const children = doc.content.root.children as any[];
|
||||
console.log(`--- ${children.length} children found`);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
console.log(`\n[${i}] type=${child.type} blockType=${child.blockType}`);
|
||||
if (child.fields) {
|
||||
console.log(' fields keys:', Object.keys(child.fields));
|
||||
if (child.fields.items) console.log(' fields.items count:', child.fields.items.length);
|
||||
if (child.fields.technicalItems) console.log(' fields.technicalItems count:', child.fields.technicalItems.length);
|
||||
if (child.fields.voltageTables) console.log(' fields.voltageTables count:', child.fields.voltageTables.length);
|
||||
}
|
||||
// Also check top-level (in case fields are flat)
|
||||
const topKeys = Object.keys(child).filter(k => !['children', 'type', 'version', 'format', 'indent', 'direction', 'textFormat', 'textStyle', 'fields'].includes(k));
|
||||
if (topKeys.length > 0) console.log(' top-level keys:', topKeys);
|
||||
if (child.items) console.log(' items (top-level) count:', child.items.length);
|
||||
if (child.technicalItems) console.log(' technicalItems (top-level) count:', child.technicalItems.length);
|
||||
if (child.voltageTables) console.log(' voltageTables (top-level) count:', child.voltageTables.length);
|
||||
}
|
||||
} else {
|
||||
console.log('No content.root.children');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
24
scripts/debug-product.ts
Normal file
24
scripts/debug-product.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
|
||||
async function debug() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
slug: { equals: 'na2xsy' }
|
||||
},
|
||||
locale: 'de'
|
||||
});
|
||||
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
console.log('Product:', doc.title);
|
||||
console.log('Content Blocks:', JSON.stringify(doc.content?.root?.children?.filter((n: any) => n.type === 'block'), null, 2));
|
||||
} else {
|
||||
console.log('Product not found');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
debug();
|
||||
459
scripts/generate-brochure.ts
Normal file
459
scripts/generate-brochure.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
#!/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 (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: [item.value] });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(productTabsBlock.fields.voltageTables)) {
|
||||
for (const vt of productTabsBlock.fields.voltageTables) {
|
||||
if (vt.voltageLabel) {
|
||||
attributes.push({ name: 'Voltage', options: [vt.voltageLabel] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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. What we do — short label, long subtitle becomes the description
|
||||
if (messages.Home?.whatWeDo) {
|
||||
const label = locale === 'de' ? 'Unser Angebot' : 'Our Services';
|
||||
sections.push({
|
||||
title: messages.Home.whatWeDo.title,
|
||||
subtitle: label,
|
||||
description: messages.Home.whatWeDo.subtitle,
|
||||
items: messages.Home.whatWeDo.items,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Our Legacy — with stats highlight
|
||||
if (messages.Team?.legacy) {
|
||||
const label = locale === 'de' ? 'Unsere Geschichte' : 'Our Story';
|
||||
sections.push({
|
||||
title: messages.Team.legacy.title,
|
||||
subtitle: label,
|
||||
description: `${messages.Team.legacy.p1}\n\n${messages.Team.legacy.p2}`,
|
||||
highlights: [
|
||||
{ value: messages.Team.legacy.expertise || 'Expertise', label: messages.Team.legacy.expertiseDesc || '' },
|
||||
{ value: messages.Team.legacy.network || 'Netzwerk', label: messages.Team.legacy.networkDesc || '' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Experience stats section
|
||||
if (messages.Home?.experience) {
|
||||
const label = locale === 'de' ? 'Erfahrung' : 'Experience';
|
||||
sections.push({
|
||||
title: messages.Home.experience.title,
|
||||
subtitle: label,
|
||||
description: `${messages.Home.experience.p1 || ''}\n\n${messages.Home.experience.p2 || ''}`.trim(),
|
||||
highlights: [
|
||||
{ value: messages.Home.experience.certifiedQuality || (locale === 'de' ? 'Zertifizierte Qualität' : 'Certified Quality'), label: messages.Home.experience.vdeApproved || '' },
|
||||
{ value: messages.Home.experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'), label: messages.Home.experience.solutionsRange || '' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Why choose us
|
||||
if (messages.Home?.whyChooseUs) {
|
||||
const label = locale === 'de' ? 'Warum KLZ' : 'Why KLZ';
|
||||
sections.push({
|
||||
title: messages.Home.whyChooseUs.title,
|
||||
subtitle: label,
|
||||
description: messages.Home.whyChooseUs.subtitle || '',
|
||||
items: messages.Home.whyChooseUs.items,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Team intro + quotes as pull quotes
|
||||
if (messages.Team?.klaus || messages.Team?.michael) {
|
||||
const label = locale === 'de' ? 'Die Geschäftsführer' : 'The Directors';
|
||||
const title = messages.Home?.meetTheTeam?.title || (locale === 'de' ? 'Das Team' : 'The Team');
|
||||
const teamItems: Array<{ title: string; description: string }> = [];
|
||||
if (messages.Team?.klaus) {
|
||||
teamItems.push({
|
||||
title: `${messages.Team.klaus.name} – ${messages.Team.klaus.role}`,
|
||||
description: messages.Team.klaus.description,
|
||||
});
|
||||
}
|
||||
if (messages.Team?.michael) {
|
||||
teamItems.push({
|
||||
title: `${messages.Team.michael.name} – ${messages.Team.michael.role}`,
|
||||
description: messages.Team.michael.description,
|
||||
});
|
||||
}
|
||||
const desc = messages.Home?.meetTheTeam?.description || '';
|
||||
sections.push({
|
||||
title,
|
||||
subtitle: label,
|
||||
description: desc,
|
||||
items: teamItems,
|
||||
pullQuote: messages.Team.klaus?.quote || messages.Team.michael?.quote || '',
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Our Values (Manifesto)
|
||||
if (messages.Team?.manifesto) {
|
||||
const label = locale === 'de' ? 'Grundprinzipien' : 'Core Principles';
|
||||
sections.push({
|
||||
title: messages.Team.manifesto.title,
|
||||
subtitle: label,
|
||||
description: messages.Team.manifesto.tagline,
|
||||
items: messages.Team.manifesto.items,
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// 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
|
||||
];
|
||||
const galleryImages: (string | Buffer)[] = [];
|
||||
for (const gp of galleryPaths) {
|
||||
const fullPath = path.join(process.cwd(), 'public', gp);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
const buf = await sharp(fullPath).png({ quality: 80 }).resize(600).toBuffer();
|
||||
galleryImages.push(buf);
|
||||
} catch { /* skip */ }
|
||||
} else {
|
||||
galleryImages.push(Buffer.alloc(0)); // placeholder to maintain index mapping
|
||||
}
|
||||
}
|
||||
console.log(`Gallery images loaded: ${galleryImages.filter(b => (b as Buffer).length > 0).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);
|
||||
|
||||
try {
|
||||
// Render the React-PDF brochure
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
||||
} 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);
|
||||
190
scripts/generate-excel-datasheets.ts
Normal file
190
scripts/generate-excel-datasheets.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Excel Datasheet Generator
|
||||
*
|
||||
* Generates per-product .xlsx datasheets using ONLY data from Payload CMS.
|
||||
* No external Excel files required.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { buildExcelModel, ProductData as ExcelProductData } from './lib/excel-data-parser';
|
||||
|
||||
const CONFIG = {
|
||||
outputDir: path.join(process.cwd(), 'public/datasheets'),
|
||||
} as const;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductData {
|
||||
title: string;
|
||||
slug: string;
|
||||
sku: string;
|
||||
locale: string;
|
||||
categories: string[];
|
||||
description: string;
|
||||
technicalItems: Array<{ label: string; value: string; unit?: string }>;
|
||||
voltageTables: Array<{
|
||||
voltageLabel: string;
|
||||
metaItems: Array<{ label: string; value: string; unit?: string }>;
|
||||
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
|
||||
crossSections: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CMS Product Loading ────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchProductsFromCMS(locale: 'en' | 'de'): Promise<ProductData[]> {
|
||||
const products: ProductData[] = [];
|
||||
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,
|
||||
});
|
||||
|
||||
for (const doc of result.docs) {
|
||||
if (!doc.title || !doc.slug) continue;
|
||||
|
||||
const excelProductData: ExcelProductData = {
|
||||
name: String(doc.title),
|
||||
slug: String(doc.slug),
|
||||
sku: String(doc.sku || ''),
|
||||
locale,
|
||||
};
|
||||
|
||||
const parsedModel = buildExcelModel({ product: excelProductData, locale });
|
||||
|
||||
products.push({
|
||||
title: String(doc.title),
|
||||
slug: String(doc.slug),
|
||||
sku: String(doc.sku || ''),
|
||||
locale,
|
||||
categories: Array.isArray(doc.categories)
|
||||
? doc.categories.map((c: any) => String(c.category || c)).filter(Boolean)
|
||||
: [],
|
||||
description: stripHtml(String(doc.description || '')),
|
||||
technicalItems: parsedModel.ok ? parsedModel.technicalItems : [],
|
||||
voltageTables: parsedModel.ok ? parsedModel.voltageTables : [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Payload] Failed to fetch products (${locale}):`, error);
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
// ─── Excel Generation ───────────────────────────────────────────────────────────
|
||||
|
||||
function generateExcelForProduct(product: ProductData): Buffer {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const l = product.locale === 'de';
|
||||
|
||||
// ── Sheet 1: Product Info ──
|
||||
const infoData: Array<[string, string]> = [
|
||||
[l ? 'Produktname' : 'Product Name', product.title],
|
||||
[l ? 'Artikelnummer' : 'SKU', product.sku],
|
||||
[l ? 'Kategorie' : 'Category', product.categories.join(', ') || '-'],
|
||||
[l ? 'Beschreibung' : 'Description', product.description || '-'],
|
||||
];
|
||||
|
||||
const infoSheet = XLSX.utils.aoa_to_sheet(infoData);
|
||||
infoSheet['!cols'] = [{ wch: 25 }, { wch: 65 }];
|
||||
XLSX.utils.book_append_sheet(workbook, infoSheet, l ? 'Produktinfo' : 'Product Info');
|
||||
|
||||
// ── Sheet 2: Technical Data ──
|
||||
if (product.technicalItems.length > 0) {
|
||||
const techData: Array<[string, string]> = product.technicalItems.map(item => {
|
||||
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
|
||||
return [label, item.value];
|
||||
});
|
||||
|
||||
const techSheet = XLSX.utils.aoa_to_sheet([
|
||||
[l ? 'Eigenschaft' : 'Property', l ? 'Wert' : 'Value'],
|
||||
...techData
|
||||
]);
|
||||
techSheet['!cols'] = [{ wch: 40 }, { wch: 60 }];
|
||||
XLSX.utils.book_append_sheet(workbook, techSheet, l ? 'Technische Daten' : 'Technical Data');
|
||||
}
|
||||
|
||||
// ── Sheet 3+: Voltage Tables ──
|
||||
for (const table of product.voltageTables) {
|
||||
const headers = ['Configuration/Cross-section', ...table.columns.map(c => c.label)];
|
||||
const dataRows = table.crossSections.map((cs, rowIndex) => {
|
||||
return [cs, ...table.columns.map(c => c.get(rowIndex) || '-')];
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows]);
|
||||
ws['!cols'] = headers.map(() => ({ wch: 22 }));
|
||||
|
||||
const safeName = table.voltageLabel.replace(/[:\\/?*[\]]/g, '-').trim();
|
||||
const sheetName = safeName.substring(0, 31);
|
||||
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
|
||||
}
|
||||
|
||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const start = Date.now();
|
||||
console.log('Starting Excel datasheet generation (Legacy Excel Source)');
|
||||
ensureOutputDir();
|
||||
|
||||
const locales: Array<'en' | 'de'> = ['en', 'de'];
|
||||
let generated = 0;
|
||||
|
||||
for (const locale of locales) {
|
||||
console.log(`\n[${locale.toUpperCase()}] Fetching products...`);
|
||||
const products = await fetchProductsFromCMS(locale);
|
||||
console.log(`Found ${products.length} products.`);
|
||||
|
||||
for (const product of products) {
|
||||
try {
|
||||
const buffer = generateExcelForProduct(product);
|
||||
const fileName = `${product.slug}-${locale}.xlsx`;
|
||||
|
||||
const subfolder = path.join(CONFIG.outputDir, 'products');
|
||||
if (!fs.existsSync(subfolder)) fs.mkdirSync(subfolder, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(subfolder, fileName), buffer);
|
||||
console.log(`✓ Generated: ${fileName}`);
|
||||
generated++;
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed for ${product.title}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! Generated ${generated} files.`);
|
||||
console.log(`Output: ${CONFIG.outputDir}`);
|
||||
console.log(`Time: ${((Date.now() - start) / 1000).toFixed(2)}s`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
17
scripts/inspect-pages.ts
Normal file
17
scripts/inspect-pages.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const resEn = await payload.find({ collection: "pages", locale: "en" as any });
|
||||
const resDe = await payload.find({ collection: "pages", locale: "de" as any });
|
||||
|
||||
console.log("EN Pages:");
|
||||
resEn.docs.forEach(d => console.log(`- ${d.slug}: ${d.title}`));
|
||||
console.log("DE Pages:");
|
||||
resDe.docs.forEach(d => console.log(`- ${d.slug}: ${d.title}`));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
27
scripts/inspect-start.ts
Normal file
27
scripts/inspect-start.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
|
||||
async function inspectStart() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: 'start' } },
|
||||
locale: 'de'
|
||||
});
|
||||
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
console.log('Start Page:', doc.title);
|
||||
console.log('Excerpt:', doc.excerpt);
|
||||
// Print block types in content
|
||||
if (doc.content?.root?.children) {
|
||||
const blocks = doc.content.root.children.filter((n: any) => n.type === 'block');
|
||||
console.log('Blocks found:', blocks.map((b: any) => b.blockType || b.type));
|
||||
}
|
||||
} else {
|
||||
console.log('Start page not found');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
inspectStart();
|
||||
843
scripts/lib/excel-data-parser.ts
Normal file
843
scripts/lib/excel-data-parser.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const EXCEL_SOURCE_FILES = [
|
||||
path.join(process.cwd(), 'data/source/high-voltage.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/solar-cables.xlsx'),
|
||||
];
|
||||
|
||||
export interface ProductData {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
sku: string;
|
||||
translationKey?: string;
|
||||
locale?: 'en' | 'de';
|
||||
}
|
||||
|
||||
export type ExcelRow = Record<string, any>;
|
||||
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
|
||||
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
|
||||
|
||||
export type KeyValueItem = { label: string; value: string; unit?: string };
|
||||
export type VoltageTableModel = {
|
||||
voltageLabel: string;
|
||||
metaItems: KeyValueItem[];
|
||||
crossSections: string[];
|
||||
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
|
||||
};
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeValue(value: string): string {
|
||||
return stripHtml(value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
const s = Number.isInteger(n) ? String(n) : String(n);
|
||||
return s.replace(/\.0+$/, '');
|
||||
}
|
||||
|
||||
function parseNumericOption(value: string): number | null {
|
||||
const v = normalizeValue(value).replace(/,/g, '.');
|
||||
const m = v.match(/-?\d+(?:\.\d+)?/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[0]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } {
|
||||
const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null);
|
||||
if (vals.length < 3) return { ok: false, text: '' };
|
||||
const uniq = Array.from(new Set(vals));
|
||||
if (uniq.length < 4) return { ok: false, text: '' };
|
||||
uniq.sort((a, b) => a - b);
|
||||
const min = uniq[0];
|
||||
const max = uniq[uniq.length - 1];
|
||||
return { ok: true, text: `${formatNumber(min)}–${formatNumber(max)}` };
|
||||
}
|
||||
|
||||
function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string {
|
||||
const vals = (options || []).map(normalizeValue).filter(Boolean);
|
||||
if (vals.length === 0) return '';
|
||||
const uniq = Array.from(new Set(vals));
|
||||
if (uniq.length === 1) return uniq[0];
|
||||
if (uniq.length <= maxItems) return uniq.join(' / ');
|
||||
return `${uniq.slice(0, maxItems).join(' / ')} / ...`;
|
||||
}
|
||||
|
||||
function summarizeSmartOptions(label: string, options: string[] | undefined): string {
|
||||
const range = summarizeNumericRange(options);
|
||||
if (range.ok) return range.text;
|
||||
return summarizeOptions(options, 3);
|
||||
}
|
||||
|
||||
function looksNumeric(value: string): boolean {
|
||||
const v = normalizeValue(value).replace(/,/g, '.');
|
||||
return /^-?\d+(?:\.\d+)?$/.test(v);
|
||||
}
|
||||
|
||||
function normalizeUnit(unitRaw: string): string {
|
||||
const u = normalizeValue(unitRaw);
|
||||
if (!u) return '';
|
||||
if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C';
|
||||
return u
|
||||
.replace(/Ω/gi, 'Ohm')
|
||||
.replace(/[\u00B5\u03BC]/g, 'u');
|
||||
}
|
||||
|
||||
function denseAbbrevLabel(args: {
|
||||
key: string;
|
||||
locale: 'en' | 'de';
|
||||
unit?: string;
|
||||
withUnit?: boolean;
|
||||
}): string {
|
||||
const u = normalizeUnit(args.unit || '');
|
||||
const withUnit = args.withUnit ?? true;
|
||||
const unitSafe = u
|
||||
.replace(/Ω/gi, 'Ohm')
|
||||
.replace(/[\u00B5\u03BC]/g, 'u');
|
||||
const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : '';
|
||||
|
||||
switch (args.key) {
|
||||
case 'DI':
|
||||
case 'RI':
|
||||
case 'Wi':
|
||||
case 'Ibl':
|
||||
case 'Ibe':
|
||||
case 'Wm':
|
||||
case 'Rbv':
|
||||
case 'Fzv':
|
||||
case 'G':
|
||||
return `${args.key}${suffix}`;
|
||||
case 'Ik_cond':
|
||||
return `Ik${suffix}`;
|
||||
case 'Ik_screen':
|
||||
return `Ik_s${suffix}`;
|
||||
case 'Ø':
|
||||
return `Ø${suffix}`;
|
||||
case 'Cond':
|
||||
return args.locale === 'de' ? 'Leiter' : 'Cond.';
|
||||
case 'shape':
|
||||
return args.locale === 'de' ? 'Form' : 'Shape';
|
||||
case 'cap':
|
||||
return `C${suffix}`;
|
||||
case 'X':
|
||||
return `X${suffix}`;
|
||||
case 'temp_range':
|
||||
return `T${suffix}`;
|
||||
case 'max_op_temp':
|
||||
return `T_op${suffix}`;
|
||||
case 'max_sc_temp':
|
||||
return `T_sc${suffix}`;
|
||||
case 'min_store_temp':
|
||||
return `T_st${suffix}`;
|
||||
case 'min_lay_temp':
|
||||
return `T_lay${suffix}`;
|
||||
case 'cpr':
|
||||
return `CPR${suffix}`;
|
||||
case 'flame':
|
||||
return `FR${suffix}`;
|
||||
case 'test_volt':
|
||||
return `U_test${suffix}`;
|
||||
case 'rated_volt':
|
||||
return `U0/U${suffix}`;
|
||||
default:
|
||||
return args.key || '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatExcelHeaderLabel(key: string, unit?: string): string {
|
||||
const k = normalizeValue(key);
|
||||
if (!k) return '';
|
||||
const u = normalizeValue(unit || '');
|
||||
|
||||
const compact = k
|
||||
.replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!u) return compact;
|
||||
if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact;
|
||||
return `${compact} (${u})`;
|
||||
}
|
||||
|
||||
function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
|
||||
const key = normalizeValue(args.key);
|
||||
if (args.locale === 'de') {
|
||||
switch (key) {
|
||||
case 'test_volt':
|
||||
return 'Prüfspannung';
|
||||
case 'temp_range':
|
||||
return 'Temperaturbereich';
|
||||
case 'max_op_temp':
|
||||
return 'Leitertemperatur (max.)';
|
||||
case 'max_sc_temp':
|
||||
return 'Kurzschlusstemperatur (max.)';
|
||||
case 'min_lay_temp':
|
||||
return 'Minimale Verlegetemperatur';
|
||||
case 'min_store_temp':
|
||||
return 'Minimale Lagertemperatur';
|
||||
case 'cpr':
|
||||
return 'CPR-Klasse';
|
||||
case 'flame':
|
||||
return 'Flammhemmend';
|
||||
default:
|
||||
return formatExcelHeaderLabel(args.excelKey);
|
||||
}
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'test_volt':
|
||||
return 'Test voltage';
|
||||
case 'temp_range':
|
||||
return 'Operating temperature range';
|
||||
case 'max_op_temp':
|
||||
return 'Conductor temperature (max.)';
|
||||
case 'max_sc_temp':
|
||||
return 'Short-circuit temperature (max.)';
|
||||
case 'min_lay_temp':
|
||||
return 'Minimum laying temperature';
|
||||
case 'min_store_temp':
|
||||
return 'Minimum storage temperature';
|
||||
case 'cpr':
|
||||
return 'CPR class';
|
||||
case 'flame':
|
||||
return 'Flame retardant';
|
||||
default:
|
||||
return formatExcelHeaderLabel(args.excelKey);
|
||||
}
|
||||
}
|
||||
|
||||
function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
|
||||
const k = normalizeValue(args.key);
|
||||
|
||||
if (args.locale === 'de') {
|
||||
switch (k) {
|
||||
case 'DI': return 'Durchmesser über Isolierung';
|
||||
case 'RI': return 'DC-Leiterwiderstand (20 °C)';
|
||||
case 'Wi': return 'Isolationsdicke';
|
||||
case 'Ibl': return 'Strombelastbarkeit in Luft (trefoil)';
|
||||
case 'Ibe': return 'Strombelastbarkeit im Erdreich (trefoil)';
|
||||
case 'Ik_cond': return 'Kurzschlussstrom Leiter';
|
||||
case 'Ik_screen': return 'Kurzschlussstrom Schirm';
|
||||
case 'Wm': return 'Manteldicke';
|
||||
case 'Rbv': return 'Biegeradius (min.)';
|
||||
case 'Ø': return 'Außen-Ø';
|
||||
case 'Fzv': return 'Zugkraft (max.)';
|
||||
case 'G': return 'Gewicht';
|
||||
case 'Cond':
|
||||
case 'conductor': return 'Leiter';
|
||||
case 'shape': return 'Leiterform';
|
||||
case 'insulation': return 'Isolierung';
|
||||
case 'sheath': return 'Mantel';
|
||||
case 'cap': return 'Kapazität';
|
||||
case 'ind_trefoil': return 'Induktivität (trefoil)';
|
||||
case 'ind_air_flat': return 'Induktivität (Luft, flach)';
|
||||
case 'ind_ground_flat': return 'Induktivität (Erdreich, flach)';
|
||||
case 'X': return 'Reaktanz';
|
||||
case 'test_volt': return 'Prüfspannung';
|
||||
case 'rated_volt': return 'Nennspannung';
|
||||
case 'temp_range': return 'Temperaturbereich';
|
||||
case 'max_op_temp': return 'Leitertemperatur (max.)';
|
||||
case 'max_sc_temp': return 'Kurzschlusstemperatur (max.)';
|
||||
case 'min_store_temp': return 'Minimale Lagertemperatur';
|
||||
case 'min_lay_temp': return 'Minimale Verlegetemperatur';
|
||||
case 'cpr': return 'CPR-Klasse';
|
||||
case 'flame': return 'Flammhemmend';
|
||||
case 'packaging': return 'Verpackung';
|
||||
case 'ce': return 'CE-Konformität';
|
||||
case 'norm': return 'Norm';
|
||||
case 'standard': return 'Standard';
|
||||
case 'D_screen': return 'Durchmesser über Schirm';
|
||||
case 'S_screen': return 'Metallischer Schirm';
|
||||
default: break;
|
||||
}
|
||||
|
||||
const raw = normalizeValue(args.excelKey);
|
||||
if (!raw) return '';
|
||||
return raw
|
||||
.replace(/\(approx\.?\)/gi, '(ca.)')
|
||||
.replace(/\bcapacitance\b/gi, 'Kapazität')
|
||||
.replace(/\binductance\b/gi, 'Induktivität')
|
||||
.replace(/\breactance\b/gi, 'Reaktanz')
|
||||
.replace(/\btest voltage\b/gi, 'Prüfspannung')
|
||||
.replace(/\brated voltage\b/gi, 'Nennspannung')
|
||||
.replace(/\boperating temperature range\b/gi, 'Temperaturbereich')
|
||||
.replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)')
|
||||
.replace(/\bsheath thickness\b/gi, 'Manteldicke')
|
||||
.replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)')
|
||||
.replace(/\binsulation thickness\b/gi, 'Isolationsdicke')
|
||||
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)')
|
||||
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø')
|
||||
.replace(/\bbending radius\b/gi, 'Biegeradius')
|
||||
.replace(/\bpackaging\b/gi, 'Verpackung')
|
||||
.replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität');
|
||||
}
|
||||
|
||||
return normalizeValue(args.excelKey);
|
||||
}
|
||||
|
||||
function compactNumericForLocale(value: string, locale: 'en' | 'de'): string {
|
||||
const v = normalizeValue(value);
|
||||
if (!v) return '';
|
||||
|
||||
if (/\d+xD/.test(v)) {
|
||||
const numbers = [];
|
||||
const matches = Array.from(v.matchAll(/(\d+)xD/g));
|
||||
for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]);
|
||||
if (numbers.length > 0) {
|
||||
const unique: string[] = [];
|
||||
for (const num of numbers) {
|
||||
if (!unique.includes(num)) {
|
||||
unique.push(num);
|
||||
}
|
||||
}
|
||||
return unique.join('/') + 'xD';
|
||||
}
|
||||
}
|
||||
|
||||
const hasDigit = /\d/.test(v);
|
||||
if (!hasDigit) return v;
|
||||
const trimmed = v.replace(/\s+/g, ' ').trim();
|
||||
const parts = trimmed.split(/(–|-)/);
|
||||
const out = parts.map(p => {
|
||||
if (p === '–' || p === '-') return p;
|
||||
const s = p.trim();
|
||||
if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p;
|
||||
const n = s.replace(/,/g, '.');
|
||||
|
||||
const compact = n
|
||||
.replace(/\.0+$/, '')
|
||||
.replace(/(\.\d*?)0+$/, '$1')
|
||||
.replace(/\.$/, '');
|
||||
|
||||
const hadPlus = /^\+/.test(s);
|
||||
const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact;
|
||||
return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus;
|
||||
});
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string {
|
||||
let v = normalizeValue(value);
|
||||
if (!v) return '';
|
||||
const u = normalizeValue(unit || '');
|
||||
if (u) {
|
||||
const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim();
|
||||
v = v
|
||||
.replace(/\bkg\s*\/\s*km\b/gi, '')
|
||||
.replace(/\bohm\s*\/\s*km\b/gi, '')
|
||||
.replace(/\bΩ\s*\/\s*km\b/gi, '')
|
||||
.replace(/\bu\s*f\s*\/\s*km\b/gi, '')
|
||||
.replace(/\bmh\s*\/\s*km\b/gi, '')
|
||||
.replace(/\bkA\b/gi, '')
|
||||
.replace(/\bmm\b/gi, '')
|
||||
.replace(/\bkv\b/gi, '')
|
||||
.replace(/\b°?c\b/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
v = v
|
||||
.replace(/\s*–\s*/g, '-')
|
||||
.replace(/\s*-\s*/g, '-')
|
||||
.replace(/\s*\/\s*/g, '/')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return compactNumericForLocale(v, locale);
|
||||
}
|
||||
|
||||
function normalizeVoltageLabel(raw: string): string {
|
||||
const v = normalizeValue(raw);
|
||||
if (!v) return '';
|
||||
const cleaned = v.replace(/\s+/g, ' ');
|
||||
if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV');
|
||||
const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/);
|
||||
if (!num) return cleaned;
|
||||
if (/[a-z]/i.test(cleaned)) return cleaned;
|
||||
return `${cleaned} kV`;
|
||||
}
|
||||
|
||||
function parseVoltageSortKey(voltageLabel: string): number {
|
||||
const v = normalizeVoltageLabel(voltageLabel);
|
||||
const nums = v
|
||||
.replace(/,/g, '.')
|
||||
.match(/\d+(?:\.\d+)?/g)
|
||||
?.map(n => Number(n))
|
||||
.filter(n => Number.isFinite(n));
|
||||
if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY;
|
||||
return nums[nums.length - 1];
|
||||
}
|
||||
|
||||
function normalizeExcelKey(value: string): string {
|
||||
return String(value || '')
|
||||
.toUpperCase()
|
||||
.replace(/-\d+$/g, '')
|
||||
.replace(/[^A-Z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function loadExcelRows(filePath: string): ExcelRow[] {
|
||||
const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const trimmed = out.trim();
|
||||
const jsonStart = trimmed.indexOf('[');
|
||||
if (jsonStart < 0) return [];
|
||||
const jsonText = trimmed.slice(jsonStart);
|
||||
try {
|
||||
return JSON.parse(jsonText) as ExcelRow[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getExcelIndex(): Map<string, ExcelMatch> {
|
||||
if (EXCEL_INDEX) return EXCEL_INDEX;
|
||||
const idx = new Map<string, ExcelMatch>();
|
||||
for (const file of EXCEL_SOURCE_FILES) {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const rows = loadExcelRows(file);
|
||||
|
||||
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
|
||||
const units: Record<string, string> = {};
|
||||
if (unitsRow) {
|
||||
for (const [k, v] of Object.entries(unitsRow)) {
|
||||
if (k === 'Part Number') continue;
|
||||
const unit = normalizeValue(String(v ?? ''));
|
||||
if (unit) units[k] = unit;
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
const pn = r?.['Part Number'];
|
||||
if (!pn || pn === 'Units') continue;
|
||||
const key = normalizeExcelKey(String(pn));
|
||||
if (!key) continue;
|
||||
const cur = idx.get(key);
|
||||
if (!cur) {
|
||||
idx.set(key, { rows: [r], units });
|
||||
} else {
|
||||
cur.rows.push(r);
|
||||
if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXCEL_INDEX = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
function findExcelForProduct(product: ProductData): ExcelMatch | null {
|
||||
const idx = getExcelIndex();
|
||||
const candidates = [
|
||||
product.name,
|
||||
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
|
||||
product.sku,
|
||||
product.translationKey,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const c of candidates) {
|
||||
const key = normalizeExcelKey(c);
|
||||
const match = idx.get(key);
|
||||
if (match && match.rows.length) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
|
||||
const keys = Object.keys(row || {});
|
||||
|
||||
for (const re of patterns) {
|
||||
const k = keys.find(x => {
|
||||
const key = String(x);
|
||||
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
|
||||
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
|
||||
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
|
||||
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
|
||||
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
|
||||
if (re.test('norm') && !/^norm$/i.test(key)) return false;
|
||||
return re.test(key);
|
||||
});
|
||||
if (k) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildExcelModel(args: {
|
||||
product: ProductData;
|
||||
locale: 'en' | 'de';
|
||||
}): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } {
|
||||
const match = findExcelForProduct(args.product);
|
||||
if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
|
||||
|
||||
const units = match.units || {};
|
||||
const rows = match.rows;
|
||||
|
||||
let sample = rows.find(r => r && Object.keys(r).length > 0) || {};
|
||||
let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
|
||||
|
||||
for (const r of rows) {
|
||||
const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
|
||||
if (cols > maxColumns) {
|
||||
sample = r;
|
||||
maxColumns = cols;
|
||||
}
|
||||
}
|
||||
|
||||
const columnMapping: Record<string, { header: string; unit: string; key: string }> = {
|
||||
'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' },
|
||||
'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' },
|
||||
'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' },
|
||||
'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' },
|
||||
'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
|
||||
'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
|
||||
'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
|
||||
'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
|
||||
'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
|
||||
'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
|
||||
'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' },
|
||||
'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' },
|
||||
'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' },
|
||||
'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' },
|
||||
'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' },
|
||||
'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' },
|
||||
'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
|
||||
'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
|
||||
'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
|
||||
'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
|
||||
'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
|
||||
'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' },
|
||||
'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' },
|
||||
'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' },
|
||||
'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
|
||||
'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
|
||||
'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' },
|
||||
'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' },
|
||||
'weight': { header: 'G', unit: 'kg/km', key: 'G' },
|
||||
'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' },
|
||||
'cable weight': { header: 'G', unit: 'kg/km', key: 'G' },
|
||||
'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
|
||||
'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
|
||||
'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
|
||||
'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' },
|
||||
'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
|
||||
'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
|
||||
'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
|
||||
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
|
||||
'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
|
||||
'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
|
||||
'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
|
||||
'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
|
||||
'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
|
||||
'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
|
||||
'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
|
||||
'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
|
||||
'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
|
||||
'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
|
||||
'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
|
||||
'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
|
||||
'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
|
||||
'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
|
||||
'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
|
||||
'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' },
|
||||
'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' },
|
||||
'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' },
|
||||
'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' },
|
||||
'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' },
|
||||
'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' },
|
||||
'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' },
|
||||
'conductor': { header: 'Conductor', unit: '', key: 'conductor' },
|
||||
'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' },
|
||||
'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' },
|
||||
'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' },
|
||||
'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' },
|
||||
'al foil': { header: 'Al foil', unit: '', key: 'al_foil' },
|
||||
'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' },
|
||||
'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' },
|
||||
'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' },
|
||||
'insulation': { header: 'Insulation', unit: '', key: 'insulation' },
|
||||
'sheath': { header: 'Sheath', unit: '', key: 'sheath' },
|
||||
'norm': { header: 'Norm', unit: '', key: 'norm' },
|
||||
'standard': { header: 'Standard', unit: '', key: 'standard' },
|
||||
'cpr class': { header: 'CPR class', unit: '', key: 'cpr' },
|
||||
'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' },
|
||||
'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' },
|
||||
'packaging': { header: 'Packaging', unit: '', key: 'packaging' },
|
||||
'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' },
|
||||
'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' },
|
||||
};
|
||||
|
||||
const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units');
|
||||
|
||||
const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
|
||||
for (const excelKey of excelKeys) {
|
||||
const normalized = normalizeValue(excelKey).toLowerCase();
|
||||
for (const [pattern, mapping] of Object.entries(columnMapping)) {
|
||||
if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) {
|
||||
matchedColumns.push({ excelKey, mapping });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const deduplicated: typeof matchedColumns = [];
|
||||
for (const item of matchedColumns) {
|
||||
if (!seenKeys.has(item.mapping.key)) {
|
||||
seenKeys.add(item.mapping.key);
|
||||
deduplicated.push(item);
|
||||
}
|
||||
}
|
||||
matchedColumns.length = 0;
|
||||
matchedColumns.push(...deduplicated);
|
||||
|
||||
const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
|
||||
const compatibleRows = rows.filter(r => {
|
||||
const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
|
||||
return JSON.stringify(rKeys) === JSON.stringify(sampleKeys);
|
||||
});
|
||||
|
||||
if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
|
||||
|
||||
const csKey =
|
||||
guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null;
|
||||
const voltageKey =
|
||||
guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null;
|
||||
|
||||
if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] };
|
||||
|
||||
const byVoltage = new Map<string, number[]>();
|
||||
for (let i = 0; i < compatibleRows.length; i++) {
|
||||
const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? ''));
|
||||
if (!cs) continue;
|
||||
const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : '';
|
||||
const voltageLabel = normalizeVoltageLabel(rawV || '');
|
||||
const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown');
|
||||
const arr = byVoltage.get(key) ?? [];
|
||||
arr.push(i);
|
||||
byVoltage.set(key, arr);
|
||||
}
|
||||
|
||||
const voltageTables: VoltageTableModel[] = [];
|
||||
const technicalItems: KeyValueItem[] = [];
|
||||
|
||||
const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => {
|
||||
const na = parseVoltageSortKey(a);
|
||||
const nb = parseVoltageSortKey(b);
|
||||
if (na !== nb) return na - nb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const globalConstantColumns = new Set<string>();
|
||||
|
||||
for (const { excelKey, mapping } of matchedColumns) {
|
||||
const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean);
|
||||
const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
|
||||
|
||||
if (unique.length === 1 && values.length > 0) {
|
||||
globalConstantColumns.add(excelKey);
|
||||
|
||||
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
|
||||
const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale });
|
||||
const label = formatExcelHeaderLabel(labelBase, unit);
|
||||
const value = compactCellForDenseTable(values[0], unit, args.locale);
|
||||
const existing = technicalItems.find(t => t.label === label);
|
||||
if (!existing) technicalItems.push({ label, value, unit });
|
||||
}
|
||||
}
|
||||
|
||||
const metaKeyPriority = [
|
||||
'test_volt',
|
||||
'temp_range',
|
||||
'max_op_temp',
|
||||
'max_sc_temp',
|
||||
'min_lay_temp',
|
||||
'min_store_temp',
|
||||
'cpr',
|
||||
'flame',
|
||||
];
|
||||
const metaKeyPrioritySet = new Set(metaKeyPriority);
|
||||
|
||||
for (const vKey of voltageKeysSorted) {
|
||||
const indices = byVoltage.get(vKey) || [];
|
||||
if (!indices.length) continue;
|
||||
|
||||
const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? '')));
|
||||
|
||||
const metaItems: KeyValueItem[] = [];
|
||||
const metaCandidates = new Map<string, KeyValueItem>();
|
||||
if (voltageKey) {
|
||||
const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
|
||||
metaItems.push({
|
||||
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
|
||||
value: normalizeVoltageLabel(rawV || ''),
|
||||
});
|
||||
}
|
||||
|
||||
const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
|
||||
|
||||
const denseTableKeyOrder = [
|
||||
'Cond',
|
||||
'shape',
|
||||
'cap',
|
||||
'X',
|
||||
'DI',
|
||||
'RI',
|
||||
'Wi',
|
||||
'Ibl',
|
||||
'Ibe',
|
||||
'Ik_cond',
|
||||
'Wm',
|
||||
'Rbv',
|
||||
'Ø',
|
||||
'D_screen',
|
||||
'S_screen',
|
||||
'Fzv',
|
||||
'G',
|
||||
] as const;
|
||||
const denseTableKeys = new Set<string>(denseTableKeyOrder);
|
||||
|
||||
const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null;
|
||||
let bendUnitOverride = '';
|
||||
if (bendingRadiusKey) {
|
||||
const bendVals = indices
|
||||
.map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? '')))
|
||||
.filter(Boolean);
|
||||
if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD';
|
||||
}
|
||||
|
||||
for (const { excelKey, mapping } of matchedColumns) {
|
||||
if (excelKey === csKey || excelKey === voltageKey) continue;
|
||||
|
||||
const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean);
|
||||
|
||||
if (values.length > 0) {
|
||||
const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
|
||||
let unit = normalizeUnit(units[excelKey] || mapping.unit || '');
|
||||
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
|
||||
|
||||
if (denseTableKeys.has(mapping.key)) {
|
||||
tableColumns.push({ excelKey, mapping });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value =
|
||||
unique.length === 1
|
||||
? compactCellForDenseTable(values[0], unit, args.locale)
|
||||
: summarizeSmartOptions(mapping.key, values);
|
||||
|
||||
const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale });
|
||||
|
||||
metaCandidates.set(mapping.key, { label, value, unit });
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of metaKeyPriority) {
|
||||
const item = metaCandidates.get(k);
|
||||
if (item && item.label && item.value) metaItems.push(item);
|
||||
}
|
||||
|
||||
const mappedByKey = new Map<string, { excelKey: string; mapping: { header: string; unit: string; key: string } }>();
|
||||
for (const c of tableColumns) {
|
||||
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
|
||||
}
|
||||
|
||||
const outerDiameterKey = (mappedByKey.get('Ø')?.excelKey || '') || null;
|
||||
const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null;
|
||||
|
||||
const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => {
|
||||
if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey);
|
||||
if (k === 'Cond') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const orderedTableColumns = denseTableKeyOrder
|
||||
.filter(k => mappedByKey.has(k) || canDeriveDenseKey(k))
|
||||
.map(k => {
|
||||
const existing = mappedByKey.get(k);
|
||||
if (existing) return existing;
|
||||
return {
|
||||
excelKey: '',
|
||||
mapping: { header: k, unit: '', key: k },
|
||||
};
|
||||
});
|
||||
|
||||
const columns = orderedTableColumns.map(({ excelKey, mapping }) => {
|
||||
const defaultUnitByKey: Record<string, string> = {
|
||||
DI: 'mm',
|
||||
RI: 'Ohm/km',
|
||||
Wi: 'mm',
|
||||
Ibl: 'A',
|
||||
Ibe: 'A',
|
||||
Ik_cond: 'kA',
|
||||
Wm: 'mm',
|
||||
Rbv: 'mm',
|
||||
'Ø': 'mm',
|
||||
Fzv: 'N',
|
||||
G: 'kg/km',
|
||||
};
|
||||
|
||||
let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || '');
|
||||
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
|
||||
|
||||
return {
|
||||
key: mapping.key,
|
||||
label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit),
|
||||
get: (rowIndex: number) => {
|
||||
const srcRowIndex = indices[rowIndex];
|
||||
const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : '';
|
||||
const unitLocal = unit;
|
||||
|
||||
if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) {
|
||||
const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? ''));
|
||||
const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? ''));
|
||||
const od = parseNumericOption(odRaw);
|
||||
const wm = parseNumericOption(wmRaw);
|
||||
if (od !== null && wm !== null) {
|
||||
const di = od - 2 * wm;
|
||||
if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.key === 'Cond' && !raw) {
|
||||
const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || '');
|
||||
if (/^NA/.test(pn)) return 'Al';
|
||||
if (/^N/.test(pn)) return 'Cu';
|
||||
}
|
||||
|
||||
if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale);
|
||||
|
||||
if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') {
|
||||
const n = parseNumericOption(raw);
|
||||
const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw);
|
||||
if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
|
||||
}
|
||||
|
||||
if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') {
|
||||
const n = parseNumericOption(raw);
|
||||
const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw);
|
||||
if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
|
||||
}
|
||||
|
||||
return compactCellForDenseTable(raw, unitLocal, args.locale);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
|
||||
}
|
||||
|
||||
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return { ok: true, technicalItems, voltageTables };
|
||||
}
|
||||
18
scripts/list-pages.ts
Normal file
18
scripts/list-pages.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
|
||||
async function listPages() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
locale: 'de'
|
||||
});
|
||||
|
||||
console.log('Pages detected:');
|
||||
result.docs.forEach(d => {
|
||||
console.log(`- ${d.title} (slug: ${d.slug})`);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
listPages();
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
const BASE_URL = process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const BASE_URL =
|
||||
process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
describe('OG Image Generation', () => {
|
||||
const locales = ['de', 'en'];
|
||||
@@ -18,7 +19,9 @@ describe('OG Image Generation', () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log(`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`);
|
||||
console.log(
|
||||
`\n⚠️ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`,
|
||||
);
|
||||
} catch (e) {
|
||||
isServerUp = false;
|
||||
}
|
||||
@@ -34,7 +37,7 @@ describe('OG Image Generation', () => {
|
||||
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
expect(bytes[0]).toBe(0x89);
|
||||
expect(bytes[1]).toBe(0x50);
|
||||
expect(bytes[2]).toBe(0x4E);
|
||||
expect(bytes[2]).toBe(0x4e);
|
||||
expect(bytes[3]).toBe(0x47);
|
||||
|
||||
// Check that the image is not empty and has a reasonable size
|
||||
@@ -49,7 +52,9 @@ describe('OG Image Generation', () => {
|
||||
await verifyImageResponse(response);
|
||||
}, 30000);
|
||||
|
||||
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ skip }) => {
|
||||
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({
|
||||
skip,
|
||||
}) => {
|
||||
if (!isServerUp) skip();
|
||||
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
|
||||
const response = await fetch(url);
|
||||
@@ -64,11 +69,26 @@ describe('OG Image Generation', () => {
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
it('should generate blog OG image', async ({ skip }) => {
|
||||
it('should generate static blog overview OG image', async ({ skip }) => {
|
||||
if (!isServerUp) skip();
|
||||
const url = `${BASE_URL}/de/blog/opengraph-image`;
|
||||
const response = await fetch(url);
|
||||
await verifyImageResponse(response);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
it('should generate dynamic blog post OG image', async ({ skip }) => {
|
||||
if (!isServerUp) skip();
|
||||
// Assuming 'hello-world' or a newly created post slug.
|
||||
// If it 404s, it still tests the routing, though 200 is expected for an actual post.
|
||||
const url = `${BASE_URL}/de/blog/hello-world/opengraph-image`;
|
||||
const response = await fetch(url);
|
||||
// Even if the post "hello-world" doesn't exist and returns 404 in some environments,
|
||||
// we should at least check it doesn't 500. We'll accept 200 or 404 as valid "working" states
|
||||
// vs a 500 compilation/satori error.
|
||||
expect([200, 404]).toContain(response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
await verifyImageResponse(response);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user