Update pdf-export.tsx

This commit is contained in:
2025-07-31 18:48:25 -04:00
parent 860693edcd
commit cd062d6670

View File

@@ -4,75 +4,11 @@ import {
Text, Text,
View, View,
StyleSheet, StyleSheet,
Font,
Image,
pdf, pdf,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import React from "react"; import React from "react";
// Global font registration state
let fontsRegistered = false;
// Font registration helper that works in both client and server environments
const registerFonts = () => {
try {
// Avoid duplicate registration
if (fontsRegistered) {
return;
}
// Only register custom fonts on client side for now
// Server-side will use fallback fonts to avoid path/loading issues
if (typeof window !== "undefined") {
// Register Inter font
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Variable.ttf",
fontWeight: "normal",
});
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Italic-Variable.ttf",
fontStyle: "italic",
});
// Register Azeret Mono fonts for numbers and tables - multiple weights
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "normal",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "semibold",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "bold",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf",
fontStyle: "italic",
});
}
fontsRegistered = true;
} catch {
fontsRegistered = true; // Don't keep trying
}
};
// Register fonts immediately
registerFonts();
interface InvoiceData { interface InvoiceData {
invoiceNumber: string; invoiceNumber: string;
issueDate: Date; issueDate: Date;
@@ -128,7 +64,7 @@ const styles = StyleSheet.create({
// Dense header (first page) // Dense header (first page)
denseHeader: { denseHeader: {
marginBottom: 30, marginBottom: 30,
borderBottom: "2px solid #10b981", borderBottom: "1px solid #e5e7eb",
paddingBottom: 20, paddingBottom: 20,
}, },
@@ -147,7 +83,7 @@ const styles = StyleSheet.create({
businessName: { businessName: {
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
fontSize: 18, fontSize: 18,
color: "#111827", color: "#0f0f0f",
marginBottom: 4, marginBottom: 4,
}, },
@@ -155,12 +91,13 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.3, lineHeight: 1.4,
marginBottom: 3, marginBottom: 3,
}, },
businessAddress: { businessAddress: {
fontSize: 11, fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginTop: 4, marginTop: 4,
@@ -172,36 +109,35 @@ const styles = StyleSheet.create({
}, },
invoiceTitle: { invoiceTitle: {
fontSize: 32, fontSize: 28,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#10b981", color: "#0f0f0f",
marginBottom: 8, marginBottom: 8,
}, },
invoiceNumber: { invoiceNumber: {
fontSize: 15, fontSize: 14,
fontFamily: "Courier-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#374151",
marginBottom: 4, marginBottom: 4,
}, },
statusBadge: { statusBadge: {
paddingHorizontal: 12, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 4, fontSize: 11,
fontSize: 12,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
textAlign: "center", textAlign: "center",
}, },
statusPaid: { statusPaid: {
backgroundColor: "#ecfdf5", backgroundColor: "#f9fafb",
color: "#065f46", color: "#374151",
}, },
statusUnpaid: { statusUnpaid: {
backgroundColor: "#fef3c7", backgroundColor: "#f9fafb",
color: "#92400e", color: "#6b7280",
}, },
// Details section (first page only) // Details section (first page only)
@@ -217,16 +153,16 @@ const styles = StyleSheet.create({
}, },
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 12,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#0f0f0f",
marginBottom: 12, marginBottom: 12,
}, },
clientName: { clientName: {
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
fontSize: 14, fontSize: 12,
color: "#111827", color: "#0f0f0f",
marginBottom: 2, marginBottom: 2,
}, },
@@ -234,12 +170,13 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.3, lineHeight: 1.4,
marginBottom: 2, marginBottom: 2,
}, },
clientAddress: { clientAddress: {
fontSize: 11, fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginTop: 4, marginTop: 4,
@@ -253,14 +190,15 @@ const styles = StyleSheet.create({
detailLabel: { detailLabel: {
fontSize: 11, fontSize: 11,
fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
flex: 1, flex: 1,
}, },
detailValue: { detailValue: {
fontSize: 10, fontSize: 10,
fontFamily: "Courier-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#0f0f0f",
flex: 1, flex: 1,
textAlign: "right", textAlign: "right",
}, },
@@ -269,16 +207,14 @@ const styles = StyleSheet.create({
notesSection: { notesSection: {
marginTop: 0, marginTop: 0,
marginBottom: 0, marginBottom: 0,
padding: 15, padding: 12,
backgroundColor: "#f9fafb", backgroundColor: "#f9fafb",
borderRadius: 4,
border: "1px solid #e5e7eb",
}, },
notesTitle: { notesTitle: {
fontSize: 12, fontSize: 11,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#0f0f0f",
marginBottom: 6, marginBottom: 6,
}, },
@@ -293,7 +229,7 @@ const styles = StyleSheet.create({
fontSize: 9, fontSize: 9,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.2, lineHeight: 1.4,
}, },
// Separator styles // Separator styles
@@ -309,32 +245,32 @@ const styles = StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: 20, marginBottom: 20,
paddingBottom: 15, paddingBottom: 12,
borderBottom: "1px solid #e5e7eb", borderBottom: "1px solid #e5e7eb",
}, },
abridgedBusinessName: { abridgedBusinessName: {
fontSize: 12, fontSize: 12,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#0f0f0f",
}, },
abridgedInvoiceInfo: { abridgedInvoiceInfo: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 15, gap: 12,
}, },
abridgedInvoiceTitle: { abridgedInvoiceTitle: {
fontSize: 16, fontSize: 14,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#10b981", color: "#0f0f0f",
}, },
abridgedInvoiceNumber: { abridgedInvoiceNumber: {
fontSize: 13, fontSize: 12,
fontFamily: "Courier-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#374151",
}, },
// Table styles // Table styles
@@ -345,16 +281,16 @@ const styles = StyleSheet.create({
tableHeader: { tableHeader: {
flexDirection: "row", flexDirection: "row",
backgroundColor: "#f3f4f6", backgroundColor: "#f9fafb",
borderBottom: "2px solid #10b981", borderBottom: "1px solid #e5e7eb",
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
tableHeaderCell: { tableHeaderCell: {
fontSize: 11, fontSize: 10,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#111827", color: "#374151",
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
@@ -395,9 +331,10 @@ const styles = StyleSheet.create({
tableCell: { tableCell: {
fontSize: 10, fontSize: 10,
color: "#111827", color: "#0f0f0f",
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 2, paddingVertical: 2,
fontFamily: "Helvetica",
}, },
tableCellDate: { tableCellDate: {
@@ -413,6 +350,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 2, paddingHorizontal: 2,
textAlign: "left", textAlign: "left",
flexWrap: "wrap", flexWrap: "wrap",
fontFamily: "Helvetica",
}, },
tableCellHours: { tableCellHours: {
@@ -454,10 +392,8 @@ const styles = StyleSheet.create({
totalsBox: { totalsBox: {
width: "100%", width: "100%",
padding: 10, padding: 12,
backgroundColor: "#f9fafb", backgroundColor: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 4,
}, },
totalRow: { totalRow: {
@@ -468,43 +404,42 @@ const styles = StyleSheet.create({
}, },
totalLabel: { totalLabel: {
fontSize: 12, fontSize: 11,
color: "#475569", color: "#6b7280",
fontFamily: "Helvetica", fontFamily: "Helvetica",
}, },
totalAmount: { totalAmount: {
fontSize: 12, fontSize: 11,
fontFamily: "Courier-Bold", fontFamily: "Courier-Bold",
color: "#1e293b", color: "#0f0f0f",
}, },
finalTotalRow: { finalTotalRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
marginTop: 6, marginTop: 8,
paddingTop: 6, paddingTop: 8,
}, },
finalTotalLabel: { finalTotalLabel: {
fontSize: 14, fontSize: 12,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#1f2937", color: "#0f0f0f",
}, },
finalTotalAmount: { finalTotalAmount: {
fontSize: 15, fontSize: 14,
fontFamily: "Courier-Bold", fontFamily: "Courier-Bold",
color: "#10b981", color: "#0f0f0f",
}, },
itemCount: { itemCount: {
fontSize: 9, fontSize: 9,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#64748b", color: "#9ca3af",
textAlign: "center", textAlign: "center",
marginTop: 6, marginTop: 6,
fontStyle: "italic",
}, },
// Footer // Footer
@@ -516,18 +451,19 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
paddingTop: 15, paddingTop: 12,
borderTop: "1px solid #e5e7eb", borderTop: "1px solid #e5e7eb",
}, },
footerLogo: { footerLogo: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "baseline",
gap: 8, gap: 4,
}, },
pageNumber: { pageNumber: {
fontSize: 10, fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
}, },
}); });
@@ -561,9 +497,21 @@ const getStatusLabel = (status: string) => {
}; };
const getStatusStyle = (status: string) => { const getStatusStyle = (status: string) => {
switch (status) { switch (status.toLowerCase()) {
case "paid": case "paid":
return [styles.statusBadge, styles.statusPaid]; return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default: default:
return [styles.statusBadge, styles.statusUnpaid]; return [styles.statusBadge, styles.statusUnpaid];
} }
@@ -914,13 +862,25 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const Footer: React.FC = () => ( const Footer: React.FC = () => (
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<View style={styles.footerLogo}> <View style={styles.footerLogo}>
{/* eslint-disable-next-line jsx-a11y/alt-text */} <Text
<Image
src="/beenvoice-logo.png"
style={{ style={{
height: 24, fontSize: 12,
fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
}} }}
/> >
beenvoice
</Text>
<Text
style={{
fontSize: 9,
fontFamily: "Helvetica",
color: "#6b7280",
marginLeft: 8,
}}
>
Professional Invoicing
</Text>
</View> </View>
<Text <Text
style={styles.pageNumber} style={styles.pageNumber}
@@ -944,13 +904,12 @@ const TotalsSection: React.FC<{
<View style={styles.totalsBox}> <View style={styles.totalsBox}>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 11,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#334155", color: "#0f0f0f",
textAlign: "center", textAlign: "center",
marginBottom: 6, marginBottom: 8,
paddingBottom: 4, paddingBottom: 6,
borderBottom: "1px solid #e2e8f0",
}} }}
> >
INVOICE SUMMARY INVOICE SUMMARY
@@ -1066,9 +1025,6 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
// Export functions // Export functions
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> { export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
try { try {
// Ensure fonts are registered
registerFonts();
// Validate invoice data // Validate invoice data
if (!invoice) { if (!invoice) {
throw new Error("Invoice data is required"); throw new Error("Invoice data is required");
@@ -1082,13 +1038,8 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
throw new Error("Client information is required"); throw new Error("Client information is required");
} }
// Generate PDF blob with timeout // Generate PDF blob
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob(); const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("PDF generation timed out")), 30000),
);
const blob = await Promise.race([pdfPromise, timeoutPromise]);
// Validate blob // Validate blob
if (!blob || blob.size === 0) { if (!blob || blob.size === 0) {
@@ -1102,22 +1053,8 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
// Download the PDF // Download the PDF
saveAs(blob, filename); saveAs(blob, filename);
} catch (error) { } catch (error) {
// Provide more specific error messages // Log the actual error for debugging
if (error instanceof Error) { console.error("PDF generation error:", error);
if (error.message.includes("timeout")) {
throw new Error("PDF generation took too long. Please try again.");
} else if (error.message.includes("empty")) {
throw new Error("Generated PDF is invalid. Please try again.");
} else if (error.message.includes("required")) {
throw new Error(error.message);
} else if (
error.message.includes("font") ||
error.message.includes("Font")
) {
throw new Error("Font loading error. Please try again.");
}
}
throw new Error("Failed to generate PDF. Please try again."); throw new Error("Failed to generate PDF. Please try again.");
} }
} }
@@ -1127,9 +1064,6 @@ export async function generateInvoicePDFBlob(
invoice: InvoiceData, invoice: InvoiceData,
): Promise<Blob> { ): Promise<Blob> {
try { try {
// Ensure fonts are registered (important for server-side generation)
registerFonts();
// Validate invoice data // Validate invoice data
if (!invoice) { if (!invoice) {
throw new Error("Invoice data is required"); throw new Error("Invoice data is required");
@@ -1143,43 +1077,20 @@ export async function generateInvoicePDFBlob(
throw new Error("Client information is required"); throw new Error("Client information is required");
} }
// Generate PDF blob with timeout (same as generateInvoicePDF) // Generate PDF blob
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob(); const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("PDF generation timed out")), 30000),
);
const blob = await Promise.race([pdfPromise, timeoutPromise]);
// Validate blob // Validate blob
if (!blob || blob.size === 0) { if (!blob || blob.size === 0) {
throw new Error("Generated PDF is empty"); throw new Error("Generated PDF is empty");
} }
return blob; return blob;
} catch (error) { } catch (error) {
// Provide more specific error messages (same as generateInvoicePDF) // Re-throw with consistent error handling
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes("timeout")) { throw error;
throw new Error("PDF generation took too long. Please try again.");
} else if (error.message.includes("empty")) {
throw new Error("Generated PDF is invalid. Please try again.");
} else if (error.message.includes("required")) {
throw new Error(error.message);
} else if (
error.message.includes("font") ||
error.message.includes("Font")
) {
throw new Error("Font loading error. Please try again.");
} else if (
error.message.includes("Cannot resolve") ||
error.message.includes("Failed to load")
) {
throw new Error("Resource loading error during PDF generation.");
}
} }
throw new Error("Failed to generate PDF blob");
throw new Error(
`Failed to generate PDF for email attachment: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }