386 lines
8.8 KiB
TypeScript
386 lines
8.8 KiB
TypeScript
import React from 'react';
|
|
|
|
import { Document, Page, View, Text, StyleSheet, Link, Image } from '@react-pdf/renderer';
|
|
|
|
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
|
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
|
|
|
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
|
const C = {
|
|
navy: '#001a4d',
|
|
navyDeep: '#000d26',
|
|
accent: '#82ed20',
|
|
white: '#FFFFFF',
|
|
offWhite: '#f8f9fa',
|
|
gray100: '#f3f4f6',
|
|
gray200: '#e5e7eb',
|
|
gray300: '#d1d5db',
|
|
gray400: '#9ca3af',
|
|
gray600: '#4b5563',
|
|
gray900: '#111827',
|
|
};
|
|
|
|
const MARGIN = 72;
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
color: C.gray900,
|
|
lineHeight: 1.6,
|
|
backgroundColor: C.white,
|
|
paddingTop: 50,
|
|
paddingBottom: 80,
|
|
fontFamily: 'Helvetica',
|
|
},
|
|
|
|
// Premium Header Layout
|
|
hero: {
|
|
backgroundColor: C.offWhite,
|
|
paddingTop: 24,
|
|
paddingBottom: 24,
|
|
paddingHorizontal: MARGIN,
|
|
marginBottom: 40,
|
|
marginTop: -50, // Counters the page padding to achieve full-bleed top
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: C.gray200,
|
|
position: 'relative',
|
|
},
|
|
|
|
headerTop: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 32,
|
|
},
|
|
|
|
logo: {
|
|
width: 140, // Increased to fit full logo
|
|
height: 45,
|
|
objectFit: 'contain',
|
|
},
|
|
|
|
docType: {
|
|
fontSize: 9,
|
|
fontWeight: 700,
|
|
color: C.navy,
|
|
letterSpacing: 2,
|
|
textTransform: 'uppercase',
|
|
opacity: 0.6,
|
|
},
|
|
|
|
headerTitleArea: {
|
|
marginTop: 0,
|
|
},
|
|
|
|
pageTitle: {
|
|
fontSize: 16,
|
|
fontWeight: 700,
|
|
color: C.navyDeep,
|
|
marginBottom: 4,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0,
|
|
},
|
|
|
|
accentBar: {
|
|
width: 40,
|
|
height: 4,
|
|
backgroundColor: C.accent,
|
|
marginTop: 12,
|
|
borderRadius: 2,
|
|
},
|
|
|
|
// Content Area
|
|
content: {
|
|
paddingHorizontal: MARGIN,
|
|
},
|
|
|
|
// Lexical Elements with high-fidelity formatting
|
|
paragraph: {
|
|
fontSize: 10,
|
|
color: C.gray600,
|
|
lineHeight: 1.7,
|
|
marginBottom: 14,
|
|
textAlign: 'justify',
|
|
},
|
|
|
|
heading1: {
|
|
fontSize: 14,
|
|
fontWeight: 700,
|
|
color: C.navyDeep,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
marginTop: 0,
|
|
marginBottom: 0,
|
|
},
|
|
|
|
heading1Wrapper: {
|
|
marginTop: 24,
|
|
marginBottom: 12,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: C.accent,
|
|
paddingLeft: 12,
|
|
},
|
|
|
|
heading2: {
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
color: C.navyDeep,
|
|
marginTop: 18,
|
|
marginBottom: 10,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
},
|
|
|
|
heading3: {
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: C.navyDeep,
|
|
marginTop: 14,
|
|
marginBottom: 8,
|
|
},
|
|
|
|
list: {
|
|
marginBottom: 16,
|
|
marginLeft: 4,
|
|
},
|
|
|
|
listItem: {
|
|
flexDirection: 'row',
|
|
marginBottom: 6,
|
|
},
|
|
|
|
listItemBullet: {
|
|
width: 20,
|
|
fontSize: 10,
|
|
color: C.gray400,
|
|
fontWeight: 700,
|
|
},
|
|
|
|
listItemContent: {
|
|
flex: 1,
|
|
fontSize: 10,
|
|
color: C.gray600,
|
|
lineHeight: 1.7,
|
|
textAlign: 'justify',
|
|
},
|
|
|
|
link: {
|
|
color: C.navy,
|
|
textDecoration: 'underline',
|
|
},
|
|
|
|
textBold: {
|
|
fontWeight: 700,
|
|
fontFamily: 'Helvetica-Bold',
|
|
color: C.navyDeep,
|
|
},
|
|
|
|
textItalic: {
|
|
fontStyle: 'italic',
|
|
},
|
|
|
|
// Industrial Footer
|
|
footer: {
|
|
position: 'absolute',
|
|
bottom: 30,
|
|
left: MARGIN,
|
|
right: MARGIN,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-end',
|
|
paddingTop: 16,
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: C.gray200,
|
|
},
|
|
|
|
footerInfo: {
|
|
flexDirection: 'column',
|
|
},
|
|
|
|
footerBrand: {
|
|
fontSize: 10,
|
|
fontWeight: 700,
|
|
color: C.navyDeep,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
},
|
|
|
|
footerText: {
|
|
fontSize: 7,
|
|
color: C.gray400,
|
|
fontWeight: 500,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
},
|
|
|
|
pageNum: {
|
|
fontSize: 8,
|
|
color: C.gray400,
|
|
},
|
|
});
|
|
|
|
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
|
|
|
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
|
if (!node) return null;
|
|
|
|
switch (node.type) {
|
|
case 'text': {
|
|
const format = node.format || 0;
|
|
const isBold = (format & 1) !== 0;
|
|
const isItalic = (format & 2) !== 0;
|
|
|
|
let elementStyle: any = {};
|
|
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
|
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
|
|
|
return (
|
|
<Text key={idx} style={elementStyle}>
|
|
{node.text}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
case 'paragraph': {
|
|
if (!node.children || node.children.length === 0) return null;
|
|
return (
|
|
<Text key={idx} style={styles.paragraph} minPresenceAhead={15}>
|
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
case 'heading': {
|
|
if (!node.children || node.children.length === 0) return null;
|
|
let hStyle = styles.heading3;
|
|
let isH1 = false;
|
|
if (node.tag === 'h1') {
|
|
hStyle = styles.heading1;
|
|
isH1 = true;
|
|
}
|
|
if (node.tag === 'h2') hStyle = styles.heading2;
|
|
|
|
const content = (
|
|
<Text key={idx} style={hStyle} wrap={false} minPresenceAhead={100}>
|
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
|
</Text>
|
|
);
|
|
|
|
if (isH1) {
|
|
return (
|
|
<View key={idx} style={styles.heading1Wrapper} wrap={false} minPresenceAhead={100}>
|
|
{content}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
case 'list': {
|
|
if (!node.children || node.children.length === 0) return null;
|
|
return (
|
|
<View key={idx} style={styles.list}>
|
|
{node.children?.map((child: any, i: number) => {
|
|
if (child.type === 'listitem') {
|
|
return (
|
|
<View key={i} style={styles.listItem} wrap={false}>
|
|
<Text style={styles.listItemBullet}>
|
|
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
|
</Text>
|
|
<Text style={styles.listItemContent}>
|
|
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
return renderLexicalNode(child, i);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
case 'link': {
|
|
const href = node.fields?.url || node.url || '#';
|
|
return (
|
|
<Link key={idx} src={href} style={styles.link}>
|
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
case 'linebreak': {
|
|
return <Text key={idx}>{'\n'}</Text>;
|
|
}
|
|
|
|
// Ignore payload blocks recursively to avoid crashing
|
|
case 'block':
|
|
return null;
|
|
|
|
default:
|
|
if (node.children) {
|
|
return (
|
|
<Text key={idx}>
|
|
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
|
</Text>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
interface PDFPageProps {
|
|
page: any;
|
|
locale?: string;
|
|
}
|
|
|
|
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
|
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
|
|
const logoPath = `${process.cwd()}/public/logo-full.png`;
|
|
|
|
return (
|
|
<Document>
|
|
<Page size="A4" style={styles.page}>
|
|
{/* Improved Hero Header - No longer fixed so it doesn't repeat on all pages */}
|
|
<View style={styles.hero}>
|
|
<View style={styles.headerTop}>
|
|
<Image src={logoPath} style={styles.logo} />
|
|
</View>
|
|
|
|
<View style={styles.headerTitleArea}>
|
|
<Text style={styles.pageTitle}>{page.title}</Text>
|
|
<View style={styles.accentBar} />
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.content}>
|
|
<View>
|
|
{page.content?.root?.children?.map((node: any, i: number) =>
|
|
renderLexicalNode(node, i),
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Industrial footer with page numbers */}
|
|
<View style={{ ...styles.footer, position: 'absolute', bottom: 40, height: 40 }} fixed>
|
|
<View style={styles.footerInfo}>
|
|
<Text style={styles.footerBrand}>KLZ VERTRIEBS GMBH</Text>
|
|
<Text style={styles.footerText}>
|
|
RAIFFEISENSTRASSE 22 • 73630 REMSHALDEN • {dateStr}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
style={styles.pageNum}
|
|
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
|
|
/>
|
|
</View>
|
|
</Page>
|
|
</Document>
|
|
);
|
|
};
|