fix: match customer draft layout and correctly use PNG assets for QR Code and Logos
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Successful in 1m59s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s

This commit is contained in:
2026-06-09 11:23:44 +02:00
parent 46a940f234
commit 1f07999d48
7 changed files with 257 additions and 157 deletions

View File

@@ -1,154 +1,201 @@
import * as React from 'react';
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
import * as path from 'path';
// Wir nutzen Standard-Helvetica, da das Projekt Helvetica für sauberen Tech-Look vorsieht.
const styles = StyleSheet.create({
// Size incl. 3mm bleed on all sides
// 85x55 -> 91x61 -> 257.95 x 172.91 pt
pageFront: {
backgroundColor: '#001a4d', // KLZ Navy
width: 257.95,
height: 172.91,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
fontFamily: 'Helvetica',
},
pageBack: {
backgroundColor: '#FFFFFF',
width: 257.95,
height: 172.91,
flexDirection: 'column',
justifyContent: 'center',
padding: 24,
position: 'relative',
fontFamily: 'Helvetica',
},
logo: {
width: 100, // Logo breite
},
accentLine: {
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
height: 8,
backgroundColor: '#82ed20', // KLZ Green
},
accentLineTop: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: 8,
backgroundColor: '#82ed20', // KLZ Green
},
personInfo: {
marginBottom: 16,
},
name: {
fontSize: 16,
fontWeight: 'bold',
color: '#000d26',
letterSpacing: -0.5,
marginBottom: 2,
textTransform: 'uppercase',
},
position: {
fontSize: 8,
color: '#82ed20', // Highlight im Text
textTransform: 'uppercase',
letterSpacing: 1,
fontWeight: 'bold',
},
contactGrid: {
marginTop: 10,
display: 'flex',
flexDirection: 'column',
gap: 4,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
},
contactLabel: {
width: 40,
fontSize: 7,
color: '#4b5563',
fontWeight: 'bold',
textTransform: 'uppercase',
},
contactValue: {
fontSize: 8,
color: '#111827',
},
logoTextWhite: {
fontSize: 32,
fontWeight: 700,
color: '#FFFFFF',
letterSpacing: 4,
marginBottom: 4,
},
logoTextBlue: {
position: 'absolute',
bottom: 24,
right: 24,
fontSize: 24,
fontWeight: 700,
color: '#001a4d',
letterSpacing: 2,
opacity: 0.3,
},
});
import React from 'react';
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
export interface PersonData {
name: string;
position: string;
role: string;
phone: string;
email: string;
website: string;
}
interface PDFBusinessCardProps {
person: PersonData;
logoWhitePath: string;
logoBluePath: string;
logoFullPath: string;
truckImagePath: string;
qrCodeDataUrl: string;
}
export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({ person }) => {
const styles = StyleSheet.create({
page: {
width: '85mm',
height: '55mm',
backgroundColor: '#ffffff',
position: 'relative',
fontFamily: 'Helvetica',
},
pageBleed: {
// 85x55mm + 3mm bleed on each side = 91x61mm
width: '91mm',
height: '61mm',
backgroundColor: '#ffffff',
position: 'relative',
fontFamily: 'Helvetica',
overflow: 'hidden',
},
// FRONT PAGE
frontContainer: {
flex: 1,
position: 'relative',
padding: '8mm',
},
frontLogo: {
position: 'absolute',
top: '5mm',
right: '5mm',
width: '25mm',
},
truckImage: {
position: 'absolute',
bottom: '-2mm',
left: '-5mm',
width: '100mm', // cover bleed width
height: '50mm',
objectFit: 'contain',
},
// BACK PAGE
backContainer: {
flex: 1,
position: 'relative',
padding: '6mm 6mm',
},
backLogo: {
width: '20mm',
marginBottom: '8mm',
},
backMiddle: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8mm',
},
nameSection: {
flexDirection: 'column',
width: '35%',
},
nameText: {
fontSize: 16,
fontWeight: 700,
color: '#000000',
marginBottom: 2,
},
roleText: {
fontSize: 8,
fontWeight: 400,
color: '#333333',
},
contactSection: {
flexDirection: 'column',
width: '45%',
paddingLeft: '2mm',
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 3,
},
iconCircle: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#82ed20',
justifyContent: 'center',
alignItems: 'center',
marginRight: 4,
},
contactText: {
fontSize: 6.5,
fontWeight: 500,
color: '#000000',
},
qrSection: {
width: '20%',
alignItems: 'flex-end',
},
qrCode: {
width: '18mm',
height: '18mm',
},
footer: {
position: 'absolute',
bottom: '5mm',
left: '6mm',
right: '6mm',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
footerText: {
fontSize: 6.5,
fontWeight: 500,
color: '#000000',
},
pipe: {
fontSize: 6.5,
fontWeight: 500,
color: '#82ed20',
marginHorizontal: 3,
},
});
export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
person,
logoFullPath,
truckImagePath,
qrCodeDataUrl,
}) => {
return (
<Document>
{/* Front: Logo and Navy Background */}
<Page size={[257.95, 172.91]} style={styles.pageFront}>
<Text style={styles.logoTextWhite}>KLZ</Text>
<View style={styles.accentLine} />
<Document
title={`Business Card - ${person.name}`}
author="KLZ Cables"
creator="KLZ React-PDF Engine"
>
{/* FRONT - 91x61mm with Bleed */}
<Page size={[258, 173]} style={styles.pageBleed}>
<Image src={truckImagePath} style={styles.truckImage} />
<Image src={logoFullPath} style={styles.frontLogo} />
</Page>
{/* Back: Contact Details */}
<Page size={[257.95, 172.91]} style={styles.pageBack}>
<View style={styles.accentLineTop} />
{/* BACK - 91x61mm with Bleed */}
<Page size={[258, 173]} style={styles.pageBleed}>
<View style={styles.backContainer}>
<Image src={logoFullPath} style={styles.backLogo} />
<View style={styles.personInfo}>
<Text style={styles.name}>{person.name}</Text>
<Text style={styles.position}>{person.position}</Text>
</View>
<View style={styles.backMiddle}>
<View style={styles.nameSection}>
<Text style={styles.nameText}>{person.name}</Text>
<Text style={styles.roleText}>{person.role}</Text>
</View>
<View style={styles.contactGrid}>
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Phone</Text>
<Text style={styles.contactValue}>{person.phone}</Text>
<View style={styles.contactSection}>
<View style={styles.contactRow}>
<View style={styles.iconCircle} />
<Text style={styles.contactText}>{person.phone}</Text>
</View>
<View style={styles.contactRow}>
<View style={styles.iconCircle} />
<Text style={styles.contactText}>{person.email}</Text>
</View>
<View style={styles.contactRow}>
<View style={styles.iconCircle} />
<Text style={styles.contactText}>klz-cables.com</Text>
</View>
</View>
<View style={styles.qrSection}>
<Image src={qrCodeDataUrl} style={styles.qrCode} />
</View>
</View>
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Email</Text>
<Text style={styles.contactValue}>{person.email}</Text>
</View>
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Web</Text>
<Text style={styles.contactValue}>{person.website}</Text>
<View style={styles.footer}>
<Text style={styles.footerText}>KLZ Vertriebs GmbH</Text>
<Text style={styles.pipe}>|</Text>
<Text style={styles.footerText}>Raiffeisenstraße 22</Text>
<Text style={styles.pipe}>|</Text>
<Text style={styles.footerText}>73630 Remshalden</Text>
</View>
</View>
<Text style={styles.logoTextBlue}>KLZ</Text>
</Page>
</Document>
);

View File

@@ -28,6 +28,7 @@
"pdf-lib": "^1.17.1",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
@@ -56,6 +57,7 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^22.19.3",
"@types/nodemailer": "^7.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sharp": "^0.31.1",

43
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^19.2.4
version: 19.2.4
@@ -163,6 +166,9 @@ importers:
'@types/nodemailer':
specifier: ^7.0.5
version: 7.0.11
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^19.2.7
version: 19.2.14
@@ -2801,6 +2807,9 @@ packages:
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3990,6 +3999,9 @@ packages:
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -6167,6 +6179,10 @@ packages:
png-js@1.0.0:
resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
@@ -6286,6 +6302,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -6964,8 +6985,8 @@ packages:
third-party-web@0.26.7:
resolution: {integrity: sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==}
third-party-web@0.29.0:
resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==}
third-party-web@0.29.2:
resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==}
thread-stream@4.0.0:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
@@ -9121,7 +9142,7 @@ snapshots:
'@paulirish/trace_engine@0.0.53':
dependencies:
legacy-javascript: 0.0.1
third-party-web: 0.29.0
third-party-web: 0.29.2
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
@@ -10152,6 +10173,10 @@ snapshots:
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.15
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -11442,6 +11467,8 @@ snapshots:
dfa@1.2.0: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -14186,6 +14213,8 @@ snapshots:
png-js@1.0.0: {}
pngjs@5.0.0: {}
po-parser@2.1.1: {}
possible-typed-array-names@1.1.0: {}
@@ -14317,6 +14346,12 @@ snapshots:
- typescript
- utf-8-validate
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.14.2:
dependencies:
side-channel: 1.1.0
@@ -15196,7 +15231,7 @@ snapshots:
third-party-web@0.26.7: {}
third-party-web@0.29.0: {}
third-party-web@0.29.2: {}
thread-stream@4.0.0:
dependencies:

BIN
public/logo-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/logo-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/truck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@@ -9,45 +9,34 @@ import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { execSync } from 'child_process';
import { PDFBusinessCard, PersonData } from '../lib/pdf-business-card';
import QRCode from 'qrcode';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/downloads/business-cards'),
logoWhite: path.join(process.cwd(), 'public/logo-white.png'),
logoBlue: path.join(process.cwd(), 'public/logo-blue.png'),
logoFull: path.join(process.cwd(), 'public/logo-full.png'),
truckImage: path.join(process.cwd(), 'public/truck.png'),
};
const PEOPLE: PersonData[] = [
{
name: 'Michael Bodemer',
position: 'Owner & Managing Director',
phone: '+49 151 462 467 98',
role: 'Managing Director',
phone: '+49 171 785 2420',
email: 'michael.bodemer@klz-cables.com',
website: 'www.klz-cables.com',
},
{
name: 'Klaus Mintel',
position: 'Managing Director',
role: 'Managing Director',
phone: '+49 151 1775 2873',
email: 'klaus.mintel@klz-cables.com',
website: 'www.klz-cables.com',
},
];
async function ensureOutputDir() {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
/**
* Konvertiert ein RGB PDF zu CMYK via Ghostscript.
*/
function convertToCMYK(inputPath: string, outputPath: string) {
console.log(`- Converting to CMYK via Ghostscript...`);
try {
// Basic CMYK conversion via Ghostscript.
// -dSAFER -dBATCH -dNOPAUSE
// -sDEVICE=pdfwrite
// -sColorConversionStrategy=CMYK
// -dProcessColorModel=/DeviceCMYK
const gsCommand = `gs -dSAFER -dBATCH -dNOPAUSE -dNOCACHE -sDEVICE=pdfwrite -sColorConversionStrategy=CMYK -dProcessColorModel=/DeviceCMYK -dAutoFilterColorImages=false -dColorImageFilter=/FlateEncode -sOutputFile="${outputPath}" "${inputPath}"`;
execSync(gsCommand, { stdio: 'pipe' });
@@ -59,11 +48,33 @@ function convertToCMYK(inputPath: string, outputPath: string) {
}
async function generateBusinessCards() {
await ensureOutputDir();
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
for (const person of PEOPLE) {
console.log(`\nGenerating Business Card for: ${person.name}`);
// Generate VCard QR Code
const lastName = person.name.split(' ').slice(1).join(' ');
const firstName = person.name.split(' ')[0];
const vcard = `BEGIN:VCARD
VERSION:3.0
N:${lastName};${firstName};;;
FN:${person.name}
ORG:KLZ Vertriebs GmbH
TITLE:${person.role}
TEL;TYPE=WORK,VOICE:${person.phone}
EMAIL;TYPE=PREF,INTERNET:${person.email}
URL:https://klz-cables.com
END:VCARD`;
const qrCodeDataUrl = await QRCode.toDataURL(vcard, {
width: 400,
margin: 0,
color: { dark: '#000000', light: '#ffffff' },
});
const safeName = person.name.replace(/\s+/g, '_');
const rgbFileName = `KLZ_BusinessCard_${safeName}_RGB.pdf`;
const cmykFileName = `KLZ_BusinessCard_${safeName}_CMYK_Print.pdf`;
@@ -75,6 +86,11 @@ async function generateBusinessCards() {
const buffer = await renderToBuffer(
React.createElement(PDFBusinessCard, {
person,
logoWhitePath: CONFIG.logoWhite,
logoBluePath: CONFIG.logoBlue,
logoFullPath: CONFIG.logoFull,
truckImagePath: CONFIG.truckImage,
qrCodeDataUrl,
}),
);
@@ -93,7 +109,7 @@ async function main() {
await generateBusinessCards();
console.log(`\n✅ Finished in ${((Date.now() - start) / 1000).toFixed(2)}s`);
} catch (error) {
console.error('\\n❌ Fatal error:', error);
console.error('\n❌ Fatal error:', error);
process.exit(1);
}
}