Merge branch 'main' into feature/strapi

This commit is contained in:
2026-01-30 22:10:16 +01:00
130 changed files with 728 additions and 587 deletions

View File

@@ -39,6 +39,7 @@ type MdxProduct = {
categories: string[];
images: string[];
descriptionHtml: string;
applicationHtml: string;
};
type MdxIndex = Map<string, MdxProduct>; // key: normalized designation/title
@@ -85,9 +86,10 @@ function buildMdxIndex(locale: 'en' | 'de'): MdxIndex {
const images = Array.isArray(data.images) ? data.images.map((i: any) => normalizeValue(String(i))).filter(Boolean) : [];
const descriptionHtml = extractDescriptionFromMdxFrontmatter(data);
const applicationHtml = normalizeValue(String(data?.application || ''));
const slug = path.basename(file, '.mdx');
idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml });
idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml, applicationHtml });
}
return idx;
@@ -183,6 +185,7 @@ async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise<Product
name: title,
shortDescriptionHtml: '',
descriptionHtml,
applicationHtml: mdx?.applicationHtml || '',
images: mdx?.images || [],
featuredImage: (mdx?.images && mdx.images[0]) || null,
sku: mdx?.sku || title,
@@ -191,7 +194,12 @@ async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise<Product
locale,
categories: (mdx?.categories || []).map(name => ({ name })),
attributes: [],
voltageType: data.voltageType,
voltageType: (() => {
const cats = (mdx?.categories || []).map(c => String(c));
const isMV = cats.some(c => /medium[-\s]?voltage|mittelspannung/i.test(c));
if (isMV && data.voltageType === 'high-voltage') return 'medium-voltage';
return data.voltageType;
})(),
});
});

View File

@@ -159,83 +159,121 @@ function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
}
function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
const key = args.key;
if (args.locale === 'de') {
switch (key) {
case 'cond_mat': return 'Leitermaterial';
case 'cond_class': return 'Leiterklasse';
case 'core_ins': return 'Aderisolation';
case 'field_ctrl': return 'Feldsteuerung';
case 'screen': return 'Schirm';
case 'long_water': return 'Längswasserdichtigkeit';
case 'trans_water': return 'Querwasserdichtigkeit';
case 'sheath_mat': return 'Mantelmaterial';
case 'sheath_color': return 'Mantelfarbe';
case 'flame_ret': return 'Flammwidrigkeit';
case 'uv_res': return 'UV-beständig';
case 'max_cond_temp': return 'Max. zulässige Leitertemperatur';
case 'out_temp_fixed': return 'Zul. Kabelaußentemperatur, fest verlegt';
case 'out_temp_motion': return 'Zul. Kabelaußentemperatur, in Bewegung';
case 'max_sc_temp_val': return 'Maximale Kurzschlußtemperatur';
case 'max_sc_temp': return 'Maximale Kurzschlußtemperatur';
case 'max_op_temp': return 'Max. zulässige Leitertemperatur';
case 'min_store_temp': return 'Minimale Lagertemperatur';
case 'min_lay_temp': return 'Mindesttemperatur Verlegung';
case 'min_bend_fixed': return 'Min. Biegeradius, fest verlegt';
case 'min_lay_temp_val': return 'Mindesttemperatur Verlegung';
case 'meter_mark': return 'Metermarkierung';
case 'partial_dis': return 'Teilentladung';
case 'cap': return 'Kapazität';
case 'X': return 'Reaktanz';
case 'test_volt': return 'Prüfspannung';
case 'rated_volt': return 'Nennspannung';
case 'temp_range': return 'Temperaturbereich';
case 'Wm': return 'Manteldicke';
case 'Wi': return 'Isolationsdicke';
case 'RI': return 'DC-Leiterwiderstand (20 °C)';
case 'Ø': return 'Außen-Ø';
case 'Rbv': return 'Biegeradius';
case 'cpr': return 'CPR-Klasse';
case 'flame': return 'Flammhemmend';
case 'G': return 'Gewicht';
case 'Fzv': return 'Zugkraft';
case 'DI': return 'Durchmesser über Isolation';
case 'Ik_cond': return 'Kurzschlussstrom Leiter';
case 'Ik_screen': return 'Kurzschlussstrom Schirm';
case 'D_screen': return 'Durchmesser über Schirm';
case 'S_screen': return 'Metallischer Schirm';
case 'cross_section': return 'Querschnitt';
case 'shape': return 'Leiterform';
case 'Ibl': return 'Strombelastbarkeit (Luft)';
case 'Ibe': return 'Strombelastbarkeit (Erde)';
case 'Cond': return 'Leitermaterial';
}
} else {
switch (key) {
case 'cond_mat': return 'Conductor material';
case 'cond_class': return 'Conductor class';
case 'core_ins': return 'Core insulation';
case 'field_ctrl': return 'Field control';
case 'screen': return 'Screen';
case 'long_water': return 'Longitudinal water tightness';
case 'trans_water': return 'Transverse water tightness';
case 'sheath_mat': return 'Sheath material';
case 'sheath_color': return 'Sheath color';
case 'flame_ret': return 'Flame retardancy';
case 'uv_res': return 'UV resistant';
case 'max_cond_temp': return 'Max. permissible conductor temperature';
case 'out_temp_fixed': return 'Permissible cable outer temperature, fixed';
case 'out_temp_motion': return 'Permissible cable outer temperature, in motion';
case 'max_sc_temp_val': return 'Maximum short-circuit temperature';
case 'max_sc_temp': return 'Maximum short-circuit temperature';
case 'max_op_temp': return 'Max. permissible conductor temperature';
case 'min_store_temp': return 'Minimum storage temperature';
case 'min_lay_temp': return 'Minimum laying temperature';
case 'min_bend_fixed': return 'Min. bending radius, fixed';
case 'min_lay_temp_val': return 'Minimum laying temperature';
case 'meter_mark': return 'Meter marking';
case 'partial_dis': return 'Partial discharge';
case 'cap': return 'Capacitance';
case 'X': return 'Reactance';
case 'test_volt': return 'Test voltage';
case 'rated_volt': return 'Rated voltage';
case 'temp_range': return 'Operating temperature range';
case 'Wm': return 'Sheath thickness';
case 'Wi': return 'Insulation thickness';
case 'RI': return 'DC resistance (20 °C)';
case 'Ø': return 'Outer diameter';
case 'Rbv': return 'Bending radius';
case 'cpr': return 'CPR class';
case 'flame': return 'Flame retardant';
case 'G': return 'Weight';
case 'Fzv': return 'Pulling force';
case 'DI': return 'Diameter over insulation';
case 'Ik_cond': return 'Short-circuit current conductor';
case 'Ik_screen': return 'Short-circuit current screen';
case 'D_screen': return 'Diameter over screen';
case 'S_screen': return 'Metallic screen';
case 'cross_section': return 'Cross-section';
case 'shape': return 'Conductor shape';
case 'Ibl': return 'Current rating (air)';
case 'Ibe': return 'Current rating (ground)';
case 'Cond': return 'Conductor material';
}
}
// Fallback for unmapped keys (should be rare if columnMapping is comprehensive)
const raw = normalizeValue(args.excelKey);
if (!raw) return '';
if (args.locale === 'de') {
return raw
.replace(/\(approx\.?\)/gi, '(ca.)')
.replace(/\bconductor material\b/gi, 'Leitermaterial')
.replace(/\bconductor class\b/gi, 'Leiterklasse')
.replace(/\bcore insulation\b/gi, 'Aderisolation')
.replace(/\binsulation\b/gi, 'Aderisolation')
.replace(/\bfield control\b/gi, 'Feldsteuerung')
.replace(/\bscreen\b/gi, 'Schirm')
.replace(/\blongitudinal water tightness\b/gi, 'Längswasserdichtigkeit')
.replace(/\btransverse water tightness\b/gi, 'Querwasserdichtigkeit')
.replace(/\bsheath material\b/gi, 'Mantelmaterial')
.replace(/\bsheath color\b/gi, 'Mantelfarbe')
.replace(/\bflame retardancy\b/gi, 'Flammwidrigkeit')
.replace(/\buv resistant\b/gi, 'UV-bestandig')
.replace(/\bmax\.? permissible conductor temperature\b/gi, 'Max. zulässige Leitertemperatur')
.replace(/\bpermissible cable outer temperature, fixed\b/gi, 'Zul. Kabelaußentemperatur, fest verlegt')
.replace(/\bpermissible cable outer temperature, in motion\b/gi, 'Zul. Kabelaußentemperatur, in Bewegung')
.replace(/\bmaximum short-circuit temperature\b/gi, 'Maximale Kurzschlußtemperatur')
.replace(/\bmin\.? bending radius, fixed\b/gi, 'Min. Biegeradius, fest verlegt')
.replace(/\bminimum laying temperature\b/gi, 'Mindesttemperatur Verlegung')
.replace(/\bmeter marking\b/gi, 'Metermarkierung')
.replace(/\bpartial discharge\b/gi, 'Teilentladung')
.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 raw
.replace(/\bconductor material\b/gi, 'Conductor material')
.replace(/\bconductor class\b/gi, 'Conductor class')
.replace(/\bcore insulation\b/gi, 'Core insulation')
.replace(/\binsulation\b/gi, 'Core insulation')
.replace(/\bfield control\b/gi, 'Field control')
.replace(/\bscreen\b/gi, 'Screen')
.replace(/\blongitudinal water tightness\b/gi, 'Longitudinal water tightness')
.replace(/\btransverse water tightness\b/gi, 'Transverse water tightness')
.replace(/\bsheath material\b/gi, 'Sheath material')
.replace(/\bsheath color\b/gi, 'Sheath color')
.replace(/\bflame retardancy\b/gi, 'Flame retardancy')
.replace(/\buv resistant\b/gi, 'UV resistant')
.replace(/\bmax\.? permissible conductor temperature\b/gi, 'Max. permissible conductor temperature')
.replace(/\bpermissible cable outer temperature, fixed\b/gi, 'Permissible cable outer temperature, fixed')
.replace(/\bpermissible cable outer temperature, in motion\b/gi, 'Permissible cable outer temperature, in motion')
.replace(/\bmaximum short-circuit temperature\b/gi, 'Maximum short-circuit temperature')
.replace(/\bmin\.? bending radius, fixed\b/gi, 'Min. bending radius, fixed')
.replace(/\bminimum laying temperature\b/gi, 'Minimum laying temperature')
.replace(/\bmeter marking\b/gi, 'Meter marking')
.replace(/\bpartial discharge\b/gi, 'Partial discharge')
.replace(/\bcapacitance\b/gi, 'Capacitance')
.replace(/\binductance\b/gi, 'Inductance')
.replace(/\breactance\b/gi, 'Reactance')
.replace(/\btest voltage\b/gi, 'Test voltage')
.replace(/\brated voltage\b/gi, 'Rated voltage')
.replace(/\boperating temperature range\b/gi, 'Operating temperature range')
.replace(/\bminimum sheath thickness\b/gi, 'Sheath thickness (min.)')
.replace(/\bsheath thickness\b/gi, 'Sheath thickness')
.replace(/\bnominal insulation thickness\b/gi, 'Insulation thickness (nom.)')
.replace(/\binsulation thickness\b/gi, 'Insulation thickness')
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC resistance (20 °C)')
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Outer diameter')
.replace(/\bbending radius\b/gi, 'Bending radius')
.replace(/\bpackaging\b/gi, 'Packaging')
.replace(/\bce\s*-?conformity\b/gi, 'CE conformity');
}
@@ -253,17 +291,58 @@ function technicalValueTranslation(args: { label: string; value: string; locale:
if (/^stranded$/i.test(v)) return 'mehrdrähtig';
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 facher Durchmesser');
if (/^XLPE/i.test(v)) return v.replace(/^XLPE/i, 'VPE');
return v;
if (/^yes, with swelling tape$/i.test(v)) return 'ja, mit Quellvliess';
if (/^yes, Al-tape$/i.test(v)) return 'ja, Al-Band';
if (/^Polyethylene/i.test(v)) return v.replace(/^Polyethylene/i, 'Polyethylen');
if (/^Class 2 stranded$/i.test(v)) return 'Klasse 2 mehrdrähtig';
if (/^VPE DIX8$/i.test(v)) return 'VPE DIX8';
if (/^inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded$/i.test(v)) return 'innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert';
if (/^copper wires \+ transverse conductive helix$/i.test(v)) return 'Kupferdrähte + Querleitwendel';
if (/^Polyethylene DMP2$/i.test(v)) return 'Polyethylen DMP2';
if (/^15 times diameter$/i.test(v)) return '15 facher Durchmesser';
// Fallback for partial matches or common terms
return v
.replace(/\bcopper\b/gi, 'Kupfer')
.replace(/\baluminum\b/gi, 'Aluminium')
.replace(/\bblack\b/gi, 'schwarz')
.replace(/\bstranded\b/gi, 'mehrdrähtig')
.replace(/\byes\b/gi, 'ja')
.replace(/\bno\b/gi, 'nein')
.replace(/\bPolyethylene\b/gi, 'Polyethylen')
.replace(/\bXLPE\b/gi, 'VPE');
}
if (args.locale === 'en') {
if (/^ja$/i.test(v)) return 'yes';
if (/^nein$/i.test(v)) return 'no';
if (/^kupfer$/i.test(v)) return 'Copper';
if (/^aluminium$/i.test(v)) return 'Aluminum';
if (/^schwarz$/i.test(v)) return 'black';
if (/^mehrdrähtig$/i.test(v)) return 'stranded';
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 times diameter');
if (/^VPE/i.test(v)) return v.replace(/^VPE/i, 'XLPE');
if (/^ja, mit Quellvliess$/i.test(v)) return 'yes, with swelling tape';
if (/^ja, Al-Band$/i.test(v)) return 'yes, Al-tape';
if (/^Polyethylen/i.test(v)) return v.replace(/^Polyethylen/i, 'Polyethylene');
if (/^Klasse 2 mehrdrähtig$/i.test(v)) return 'Class 2 stranded';
if (/^innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert$/i.test(v)) return 'inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded';
if (/^Kupferdrähte \+ Querleitwendel$/i.test(v)) return 'copper wires + transverse conductive helix';
if (/^Polyethylen DMP2$/i.test(v)) return 'Polyethylene DMP2';
if (/^15 facher Durchmesser$/i.test(v)) return '15 times diameter';
// Fallback for partial matches or common terms
return v
.replace(/\bkupfer\b/gi, 'Copper')
.replace(/\baluminium\b/gi, 'Aluminum')
.replace(/\bschwarz\b/gi, 'black')
.replace(/\bmehrdrähtig\b/gi, 'stranded')
.replace(/\bja\b/gi, 'yes')
.replace(/\bnein\b/gi, 'no')
.replace(/\bPolyethylen\b/gi, 'Polyethylene')
.replace(/\bVPE\b/gi, 'XLPE');
}
if (/^ja$/i.test(v)) return 'yes';
if (/^nein$/i.test(v)) return 'no';
if (/^kupfer$/i.test(v)) return 'Copper';
if (/^aluminium$/i.test(v)) return 'Aluminum';
if (/^schwarz$/i.test(v)) return 'black';
if (/^mehrdrähtig$/i.test(v)) return 'stranded';
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 times diameter');
if (/^VPE/i.test(v)) return v.replace(/^VPE/i, 'XLPE');
return v;
}
@@ -287,6 +366,26 @@ function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de
return 'CPR-Klasse';
case 'flame':
return 'Flammhemmend';
case 'DI': return 'Durchmesser über Isolation';
case 'RI': return 'DC-Leiterwiderstand (20 °C)';
case 'Wi': return 'Isolationsdicke';
case 'Wm': return 'Manteldicke';
case 'Rbv': return 'Biegeradius';
case 'Fzv': return 'Zugkraft';
case 'G': return 'Gewicht';
case 'Ik_cond': return 'Kurzschlussstrom Leiter';
case 'Ik_screen': return 'Kurzschlussstrom Schirm';
case 'Ø': return 'Außen-Ø';
case 'cap': return 'Kapazität';
case 'X': return 'Reaktanz';
case 'rated_volt': return 'Nennspannung';
case 'D_screen': return 'Durchmesser über Schirm';
case 'S_screen': return 'Metallischer Schirm';
case 'cross_section': return 'Querschnitt';
case 'shape': return 'Leiterform';
case 'Ibl': return 'Strombelastbarkeit (Luft)';
case 'Ibe': return 'Strombelastbarkeit (Erde)';
case 'Cond': return 'Leitermaterial';
default:
return formatExcelHeaderLabel(args.excelKey);
}
@@ -309,6 +408,26 @@ function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de
return 'CPR class';
case 'flame':
return 'Flame retardant';
case 'DI': return 'Diameter over insulation';
case 'RI': return 'DC resistance (20 °C)';
case 'Wi': return 'Insulation thickness';
case 'Wm': return 'Sheath thickness';
case 'Rbv': return 'Bending radius';
case 'Fzv': return 'Pulling force';
case 'G': return 'Weight';
case 'Ik_cond': return 'Short-circuit current conductor';
case 'Ik_screen': return 'Short-circuit current screen';
case 'Ø': return 'Outer diameter';
case 'cap': return 'Capacitance';
case 'X': return 'Reactance';
case 'rated_volt': return 'Rated voltage';
case 'D_screen': return 'Diameter over screen';
case 'S_screen': return 'Metallic screen';
case 'cross_section': return 'Cross-section';
case 'shape': return 'Conductor shape';
case 'Ibl': return 'Current rating (air)';
case 'Ibe': return 'Current rating (ground)';
case 'Cond': return 'Conductor material';
default:
return formatExcelHeaderLabel(args.excelKey);
}
@@ -403,7 +522,7 @@ function translateAbbreviation(abbrev: string, description: string, locale: 'en'
case 'Cond':
return 'Leiter';
case 'shape':
return 'Form';
return 'Leiterform';
case 'cap':
return 'Kapazität';
case 'X':
@@ -701,7 +820,7 @@ function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): B
'Mantelmaterial',
'Mantelfarbe',
'Flammwidrigkeit',
'UV-bestandig',
'UV-beständig',
'Max. zulässige Leitertemperatur',
'Zul. Kabelaußentemperatur, fest verlegt',
'Zul. Kabelaußentemperatur, in Bewegung',
@@ -871,6 +990,7 @@ function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): B
}
function isMediumVoltageProduct(product: ProductData): boolean {
if (product.voltageType === 'medium-voltage') return true;
const hay = [product.slug, product.path, product.translationKey, ...(product.categories || []).map(c => c.name)]
.filter(Boolean)
.join(' ');
@@ -991,7 +1111,7 @@ function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
return {
key: col.colKey,
// Use the abbreviated title from the first row as the table header.
label: normalizeValue(col.colKey),
label: denseAbbrevLabel({ key: col.colKey, locale: args.locale, unit: col.unit }) || normalizeValue(col.colKey),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = normalizeValue(String((mv.rows[srcRowIndex] as Record<string, unknown>)?.[col.colKey] ?? ''));
@@ -1009,7 +1129,7 @@ function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
export function buildDatasheetModel(args: { product: ProductData; locale: 'en' | 'de' }): DatasheetModel {
const labels = getLabels(args.locale);
const categoriesLine = (args.product.categories || []).map(c => stripHtml(c.name)).join(' • ');
const descriptionText = stripHtml(args.product.shortDescriptionHtml || args.product.descriptionHtml || '');
const descriptionText = stripHtml(args.product.applicationHtml || '');
const heroSrc = resolveMediaToLocalPath(args.product.featuredImage || args.product.images?.[0] || null);
const productUrl = getProductUrl(args.product);
@@ -1053,22 +1173,71 @@ export function buildDatasheetModel(args: { product: ProductData; locale: 'en' |
productUrl,
},
labels,
technicalItems: [
...(excelModel.ok ? excelModel.technicalItems : []),
...(isMediumVoltageProduct(args.product)
? args.locale === 'de'
? [
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' },
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' },
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' },
]
: [
{ label: 'Test voltage 6/10 kV', value: '21 kV' },
{ label: 'Test voltage 12/20 kV', value: '42 kV' },
{ label: 'Test voltage 18/30 kV', value: '63 kV' },
]
: []),
],
technicalItems: (() => {
if (!isMediumVoltageProduct(args.product)) {
return excelModel.ok ? excelModel.technicalItems : [];
}
const pn = normalizeDesignation(args.product.name || '');
const isAl = /^NA/.test(pn);
const isFL = pn.includes('FL');
const isF = !isFL && pn.includes('F');
const findExcelVal = (labelPart: string) => {
const found = excelModel.technicalItems.find(it => it.label.toLowerCase().includes(labelPart.toLowerCase()));
return found ? found.value : null;
};
const items: KeyValueItem[] = [];
if (args.locale === 'de') {
items.push({ label: 'Leitermaterial', value: isAl ? 'Aluminium' : 'Kupfer' });
items.push({ label: 'Leiterklasse', value: isAl ? 'Klasse 1' : 'Klasse 2 mehrdrähtig' });
items.push({ label: 'Aderisolation', value: 'VPE DIX8' });
items.push({ label: 'Feldsteuerung', value: 'innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert' });
items.push({ label: 'Schirm', value: 'Kupferdrähte + Querleitwendel' });
items.push({ label: 'Längswasserdichtigkeit', value: (isF || isFL) ? 'ja, mit Quellvliess' : 'nein' });
items.push({ label: 'Querwasserdichtigkeit', value: isFL ? 'ja, Al-Band' : 'nein' });
items.push({ label: 'Mantelmaterial', value: 'Polyethylen DMP2' });
items.push({ label: 'Mantelfarbe', value: 'schwarz' });
items.push({ label: 'Flammwidrigkeit', value: 'nein' });
items.push({ label: 'UV-beständig', value: 'ja' });
items.push({ label: 'Max. zulässige Leitertemperatur', value: findExcelVal('Leitertemperatur') || '90°C' });
items.push({ label: 'Zul. Kabelaußentemperatur, fest verlegt', value: findExcelVal('fest verlegt') || '70°C' });
items.push({ label: 'Zul. Kabelaußentemperatur, in Bewegung', value: findExcelVal('in Bewegung') || '-20 °C bis +70 °C' });
items.push({ label: 'Maximale Kurzschlußtemperatur', value: findExcelVal('Kurzschlußtemperatur') || '+250 °C' });
items.push({ label: 'Min. Biegeradius, fest verlegt', value: findExcelVal('Biegeradius') || '15 facher Durchmesser' });
items.push({ label: 'Mindesttemperatur Verlegung', value: findExcelVal('Verlegung') || '-5 °C' });
items.push({ label: 'Metermarkierung', value: 'ja' });
items.push({ label: 'Teilentladung', value: findExcelVal('Teilentladung') || '2 pC' });
items.push({ label: 'Prüfspannung 6/10 kV', value: '21 kV' });
items.push({ label: 'Prüfspannung 12/20 kV', value: '42 kV' });
items.push({ label: 'Prüfspannung 18/30 kV', value: '63 kV' });
} else {
items.push({ label: 'Conductor material', value: isAl ? 'Aluminum' : 'Copper' });
items.push({ label: 'Conductor class', value: isAl ? 'Class 1' : 'Class 2 stranded' });
items.push({ label: 'Core insulation', value: 'XLPE DIX8' });
items.push({ label: 'Field control', value: 'inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded' });
items.push({ label: 'Screen', value: 'copper wires + transverse conductive helix' });
items.push({ label: 'Longitudinal water tightness', value: (isF || isFL) ? 'yes, with swelling tape' : 'no' });
items.push({ label: 'Transverse water tightness', value: isFL ? 'yes, Al-tape' : 'no' });
items.push({ label: 'Sheath material', value: 'Polyethylene DMP2' });
items.push({ label: 'Sheath color', value: 'black' });
items.push({ label: 'Flame retardancy', value: 'no' });
items.push({ label: 'UV resistant', value: 'yes' });
items.push({ label: 'Max. permissible conductor temperature', value: findExcelVal('conductor temperature') || '90°C' });
items.push({ label: 'Permissible cable outer temperature, fixed', value: findExcelVal('fixed') || '70°C' });
items.push({ label: 'Permissible cable outer temperature, in motion', value: findExcelVal('in motion') || '-20 °C to +70 °C' });
items.push({ label: 'Maximum short-circuit temperature', value: findExcelVal('short-circuit temperature') || '+250 °C' });
items.push({ label: 'Min. bending radius, fixed', value: findExcelVal('bending radius') || '15 times diameter' });
items.push({ label: 'Minimum laying temperature', value: findExcelVal('laying temperature') || '-5 °C' });
items.push({ label: 'Meter marking', value: 'yes' });
items.push({ label: 'Partial discharge', value: findExcelVal('Partial discharge') || '2 pC' });
items.push({ label: 'Test voltage 6/10 kV', value: '21 kV' });
items.push({ label: 'Test voltage 12/20 kV', value: '42 kV' });
items.push({ label: 'Test voltage 18/30 kV', value: '63 kV' });
}
return items;
})(),
voltageTables,
legendItems: crossSectionModel.legendItems || [],
};

View File

@@ -3,6 +3,7 @@ export interface ProductData {
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml: string;
images: string[];
featuredImage: string | null;
sku: string;

View File

@@ -54,7 +54,7 @@ export function getLabels(locale: 'en' | 'de') {
return {
en: {
datasheet: 'Technical Datasheet',
description: 'DESCRIPTION',
description: 'APPLICATION',
technicalData: 'TECHNICAL DATA',
crossSection: 'Cross-sections/Voltage',
sku: 'SKU',
@@ -62,7 +62,7 @@ export function getLabels(locale: 'en' | 'de') {
},
de: {
datasheet: 'Technisches Datenblatt',
description: 'BESCHREIBUNG',
description: 'ANWENDUNG',
technicalData: 'TECHNISCHE DATEN',
crossSection: 'Querschnitte/Spannung',
sku: 'ARTIKELNUMMER',

View File

@@ -21,61 +21,67 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
const headerTitle = model.labels.datasheet;
// Dense tables require compact headers (no wrapping). Use standard abbreviations.
const firstColLabel = model.locale === 'de' ? 'Adern & QS' : 'Cores & CS';
const firstColLabel = model.locale === 'de' ? 'Adern & Querschnitt' : 'Cores & Cross-section';
return (
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Text style={styles.h1}>{model.product.name}</Text>
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null}
<View style={styles.heroBox}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
<View style={styles.hero}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} isHero={true} />
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<View style={styles.productHero}>
{model.product.categoriesLine ? <Text style={styles.productMeta}>{model.product.categoriesLine}</Text> : null}
<Text style={styles.productName}>{model.product.name}</Text>
</View>
</View>
<View style={styles.productImageCol}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
</View>
</View>
</View>
{model.product.descriptionText ? (
<Section title={model.labels.description} minPresenceAhead={24}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.technicalItems.length ? (
<Section title={model.labels.technicalData} minPresenceAhead={24}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
<View style={styles.content}>
{model.product.descriptionText ? (
<Section title={model.labels.description} minPresenceAhead={24}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
{model.technicalItems.length ? (
<Section title={model.labels.technicalData} minPresenceAhead={24}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
</View>
</Page>
{/*
Render all voltage sections in a single flow so React-PDF can paginate naturally.
This avoids hard page breaks that waste remaining whitespace at the bottom of a page.
Each table section has break={false} to prevent breaking within individual tables,
but the overall flow allows tables to move to the next page if needed.
*/}
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false} minPresenceAhead={24}>
<Text style={styles.sectionTitle}>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<View style={styles.content}>
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false} minPresenceAhead={24}>
<Text style={styles.sectionTitle}>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<View style={styles.sectionAccent} />
<DenseTable table={{ columns: t.columns, rows: t.rows }} firstColLabel={firstColLabel} />
</View>
))}
<DenseTable table={{ columns: t.columns, rows: t.rows }} firstColLabel={firstColLabel} />
</View>
))}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
</View>
</Page>
</Document>
);

View File

@@ -14,9 +14,9 @@ export function Footer(props: { locale: 'en' | 'de'; siteUrl?: string }): React.
return (
<View style={styles.footer} fixed>
<Text>{siteUrl}</Text>
<Text>{date}</Text>
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{date}</Text>
<Text style={styles.footerText} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} />
</View>
);
}

View File

@@ -3,17 +3,16 @@ import { Image, Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null }): React.ReactElement {
export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null; isHero?: boolean }): React.ReactElement {
const { isHero = false } = props;
return (
<View style={styles.header} fixed>
<View style={isHero ? styles.header : [styles.header, { paddingHorizontal: 0, backgroundColor: 'transparent', borderBottomWidth: 0, marginBottom: 24, paddingTop: 40 }]}>
<View style={styles.headerLeft}>
{props.logoDataUrl ? (
<Image src={props.logoDataUrl} style={styles.logo} />
) : (
<View style={styles.brandFallback}>
<Text style={styles.brandFallbackKlz}>KLZ</Text>
<Text style={styles.brandFallbackCables}>Cables</Text>
</View>
<Text style={styles.brandFallback}>KLZ</Text>
)}
</View>
<View style={styles.headerRight}>

View File

@@ -8,37 +8,25 @@ export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactEleme
const items = (props.items || []).filter(i => i.label && i.value);
if (!items.length) return null;
// 4-column layout: (label, value, label, value)
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < items.length; i += 2) {
rows.push([items[i], items[i + 1] || null]);
}
// 2-column layout: (label, value)
return (
<View style={styles.kvGrid}>
{rows.map(([left, right], rowIndex) => {
const isLast = rowIndex === rows.length - 1;
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value;
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
{items.map((item, rowIndex) => {
const isLast = rowIndex === items.length - 1;
const value = item.unit ? `${item.value} ${item.unit}` : item.value;
return (
<View
key={`${left.label}-${rowIndex}`}
key={`${item.label}-${rowIndex}`}
style={[styles.kvRow, rowIndex % 2 === 0 ? styles.kvRowAlt : null, isLast ? styles.kvRowLast : null]}
wrap={false}
minPresenceAhead={12}
>
<View style={[styles.kvCell, { width: '18%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text>
<View style={[styles.kvCell, { width: '50%' }]}>
<Text style={styles.kvLabelText}>{item.label}</Text>
</View>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '32%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text>
</View>
<View style={[styles.kvCell, { width: '18%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '32%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
<View style={[styles.kvCell, { width: '50%' }]}>
<Text style={styles.kvValueText}>{value}</Text>
</View>
</View>
);

View File

@@ -11,8 +11,9 @@ export function Section(props: {
}): React.ReactElement {
const boxed = props.boxed ?? true;
return (
<View style={boxed ? styles.section : styles.sectionPlain} minPresenceAhead={props.minPresenceAhead}>
<View style={styles.section} minPresenceAhead={props.minPresenceAhead}>
<Text style={styles.sectionTitle}>{props.title}</Text>
<View style={styles.sectionAccent} />
{props.children}
</View>
);

View File

@@ -5,146 +5,212 @@ import { Font, StyleSheet } from '@react-pdf/renderer';
Font.registerHyphenationCallback(word => [word]);
export const COLORS = {
navy: '#0E2A47',
mediumGray: '#6B7280',
darkGray: '#1F2933',
lightGray: '#E6E9ED',
almostWhite: '#F8F9FA',
headerBg: '#F6F8FB',
primary: '#001a4d',
primaryDark: '#000d26',
accent: '#82ed20',
textPrimary: '#111827',
textSecondary: '#4b5563',
textLight: '#9ca3af',
neutral: '#f8f9fa',
border: '#e5e7eb',
} as const;
export const styles = StyleSheet.create({
page: {
paddingTop: 54,
paddingLeft: 54,
paddingRight: 54,
paddingBottom: 72,
paddingTop: 0,
paddingLeft: 30,
paddingRight: 30,
paddingBottom: 60,
fontFamily: 'Helvetica',
fontSize: 10,
color: COLORS.darkGray,
color: COLORS.textPrimary,
backgroundColor: '#FFFFFF',
},
// Hero-style header
hero: {
backgroundColor: '#FFFFFF',
paddingTop: 30,
paddingBottom: 0,
paddingHorizontal: 0,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomColor: COLORS.border,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: COLORS.headerBg,
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
marginBottom: 16,
marginBottom: 24,
paddingHorizontal: 0,
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
logo: { width: 110, height: 24, objectFit: 'contain' },
brandFallback: { flexDirection: 'row', alignItems: 'baseline', gap: 6 },
brandFallbackKlz: { fontSize: 18, fontWeight: 700, color: COLORS.navy },
brandFallbackCables: { fontSize: 10, color: COLORS.mediumGray },
logo: { width: 100, height: 22, objectFit: 'contain' },
brandFallback: { fontSize: 20, fontWeight: 700, color: COLORS.primaryDark, letterSpacing: 1, textTransform: 'uppercase' },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.navy, letterSpacing: 0.2 },
qr: { width: 34, height: 34, objectFit: 'contain' },
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.primary, letterSpacing: 1.5, textTransform: 'uppercase' },
qr: { width: 30, height: 30, objectFit: 'contain' },
productRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
productInfoCol: {
flex: 1,
justifyContent: 'center',
},
productImageCol: {
flex: 1,
height: 120,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
productHero: {
marginTop: 0,
paddingHorizontal: 0,
},
productName: {
fontSize: 24,
fontWeight: 700,
color: COLORS.primaryDark,
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
productMeta: {
fontSize: 9,
color: COLORS.textSecondary,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 4,
},
content: {
paddingHorizontal: 0,
},
footer: {
position: 'absolute',
left: 54,
right: 54,
bottom: 36,
paddingTop: 10,
left: 30,
right: 30,
bottom: 30,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: COLORS.lightGray,
borderTopColor: COLORS.border,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 8,
color: COLORS.mediumGray,
alignItems: 'center',
},
footerBrand: { fontSize: 9, fontWeight: 700, color: COLORS.primaryDark, textTransform: 'uppercase', letterSpacing: 1 },
footerText: { fontSize: 8, color: COLORS.textLight, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 },
h1: { fontSize: 18, fontWeight: 700, color: COLORS.navy, marginBottom: 6 },
subhead: { fontSize: 10.5, color: COLORS.mediumGray, marginBottom: 14 },
h1: { fontSize: 22, fontWeight: 700, color: COLORS.primaryDark, marginBottom: 8, textTransform: 'uppercase' },
subhead: { fontSize: 10, fontWeight: 700, color: COLORS.textSecondary, marginBottom: 16, textTransform: 'uppercase', letterSpacing: 0.5 },
heroBox: {
height: 110,
height: 180,
borderRadius: 12,
borderWidth: 1,
borderColor: COLORS.lightGray,
backgroundColor: COLORS.almostWhite,
marginBottom: 16,
borderColor: COLORS.border,
backgroundColor: '#FFFFFF',
marginBottom: 24,
justifyContent: 'center',
overflow: 'hidden',
padding: 0,
},
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: COLORS.mediumGray, paddingHorizontal: 12 },
noImage: { fontSize: 8, color: COLORS.textLight, textAlign: 'center' },
section: {
borderWidth: 1,
borderColor: COLORS.lightGray,
padding: 14,
marginBottom: 14,
},
sectionPlain: {
paddingVertical: 2,
marginBottom: 12,
marginBottom: 10,
},
sectionTitle: {
fontSize: 10,
fontSize: 14,
fontWeight: 700,
color: COLORS.navy,
color: COLORS.primaryDark,
marginBottom: 8,
letterSpacing: 0.2,
textTransform: 'uppercase',
letterSpacing: -0.2,
},
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray },
sectionAccent: {
width: 30,
height: 3,
backgroundColor: COLORS.accent,
marginBottom: 8,
borderRadius: 1.5,
},
body: { fontSize: 10, lineHeight: 1.6, color: COLORS.textSecondary },
kvGrid: {
width: '100%',
borderWidth: 1,
borderColor: COLORS.lightGray,
borderColor: COLORS.border,
borderRadius: 8,
overflow: 'hidden',
},
kvRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
borderBottomColor: COLORS.border,
},
kvRowAlt: { backgroundColor: COLORS.almostWhite },
kvRowAlt: { backgroundColor: COLORS.neutral },
kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
// Visual separator between (label,value) pairs in the 4-col KV grid.
// Matches the engineering-table look and improves scanability.
kvCell: { paddingVertical: 3, paddingHorizontal: 12 },
kvMidDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
borderRightColor: COLORS.border,
},
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
kvLabelText: { fontSize: 8, fontWeight: 700, color: COLORS.primaryDark, textTransform: 'uppercase', letterSpacing: 0.3 },
kvValueText: { fontSize: 9, color: COLORS.textPrimary, fontWeight: 500 },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray, marginBottom: 14 },
tableWrap: {
width: '100%',
borderWidth: 1,
borderColor: COLORS.border,
borderRadius: 8,
overflow: 'hidden',
marginBottom: 16,
},
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: '#FFFFFF',
backgroundColor: COLORS.neutral,
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
borderBottomColor: COLORS.border,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
paddingVertical: 8,
paddingHorizontal: 6,
fontSize: 7,
fontWeight: 700,
color: COLORS.navy,
color: COLORS.primaryDark,
textTransform: 'uppercase',
letterSpacing: 0.2,
},
tableHeaderCellCfg: {
paddingHorizontal: 6,
paddingHorizontal: 8,
},
tableHeaderCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
borderRightColor: COLORS.border,
},
tableRow: { width: '100%', flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
tableRowAlt: { backgroundColor: COLORS.almostWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray },
tableRow: { width: '100%', flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tableRowAlt: { backgroundColor: '#FFFFFF' },
tableCell: { paddingVertical: 6, paddingHorizontal: 6, fontSize: 7, color: COLORS.textSecondary, fontWeight: 500 },
tableCellCfg: {
paddingHorizontal: 6,
paddingHorizontal: 8,
},
tableCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
borderRightColor: COLORS.border,
},
});