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:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user