mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -05:00
Build fixes, email preview system
This commit is contained in:
1
src/lib/email-templates/index.ts
Normal file
1
src/lib/email-templates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { generateInvoiceEmailTemplate } from "./invoice-email";
|
||||
578
src/lib/email-templates/invoice-email.ts
Normal file
578
src/lib/email-templates/invoice-email.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
interface InvoiceEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
notes?: string | null;
|
||||
client: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
} | null;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
customContent?: string;
|
||||
customMessage?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function generateInvoiceEmailTemplate({
|
||||
invoice,
|
||||
customContent,
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl = "https://beenvoice.app",
|
||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getTimeOfDayGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
};
|
||||
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
const businessAddress = invoice.business
|
||||
? [
|
||||
invoice.business.addressLine1,
|
||||
invoice.business.addressLine2,
|
||||
invoice.business.city && invoice.business.state
|
||||
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode ?? ""}`.trim()
|
||||
: (invoice.business.city ?? invoice.business.state),
|
||||
invoice.business.country !== "United States"
|
||||
? invoice.business.country
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("<br>")
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="format-detection" content="date=no">
|
||||
<meta name="format-detection" content="address=no">
|
||||
<meta name="format-detection" content="email=no">
|
||||
<title>Invoice ${invoice.invoiceNumber}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
background-color: #f9fafb;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
font-weight: normal;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
color: #374151;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 32px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.invoice-card {
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.invoice-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #16a34a;
|
||||
margin-bottom: 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.invoice-date {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.invoice-amount {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.invoice-details {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: table;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
font-weight: 600;
|
||||
padding-top: 12px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
display: table-cell;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
display: table-cell;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
text-align: right;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.business-info {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.business-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.business-details {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.custom-content ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.custom-content li {
|
||||
margin: 8px 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
padding: 24px;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachment-notice {
|
||||
background-color: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: #16a34a;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-text {
|
||||
font-size: 14px;
|
||||
color: #166534;
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.signature {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.signature-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.signature-email {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #f9fafb;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
max-width: 80px;
|
||||
height: auto;
|
||||
margin: 0 auto 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Email client specific fixes */
|
||||
@media screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
width: 100% !important;
|
||||
max-width: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Outlook specific fixes */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
/* Apple Mail attachment preview fix */
|
||||
.attachment-notice {
|
||||
border: 2px dashed #bbf7d0 !important;
|
||||
background-color: #f0fdf4 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.invoice-amount {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-label,
|
||||
.detail-value {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
|
||||
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="message">
|
||||
<div class="greeting">${getTimeOfDayGreeting()},</div>
|
||||
<p>I hope this email finds you well. Please find attached invoice <strong>#${invoice.invoiceNumber}</strong>
|
||||
for the services provided. The invoice details are summarized below for your reference.</p>
|
||||
${customMessage ? `<div style="margin: 16px 0; padding: 16px; background-color: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 4px;">${customMessage}</div>` : ""}
|
||||
</div>
|
||||
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
|
||||
|
||||
<div class="invoice-card">
|
||||
<div class="invoice-header">
|
||||
<div>
|
||||
<div class="invoice-number">#${invoice.invoiceNumber}</div>
|
||||
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
|
||||
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
|
||||
</div>
|
||||
<div class="invoice-amount">
|
||||
<div class="amount-label">Total Amount</div>
|
||||
<div class="amount-value">${formatCurrency(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invoice-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Client</span>
|
||||
<span class="detail-value">${invoice.client.name}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Subtotal</span>
|
||||
<span class="detail-value">${formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
${
|
||||
invoice.taxRate > 0
|
||||
? `<div class="detail-row">
|
||||
<span class="detail-label">Tax (${invoice.taxRate}%)</span>
|
||||
<span class="detail-value">${formatCurrency(taxAmount)}</span>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Total</span>
|
||||
<span class="detail-value">${formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="attachment-notice">
|
||||
<div class="attachment-icon"></div>
|
||||
<div class="attachment-text">
|
||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-section">
|
||||
<div class="cta-text">
|
||||
If you have any questions about this invoice, please don't hesitate to reach out.
|
||||
Thank you for your business!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
!customContent
|
||||
? `<div class="signature">
|
||||
<div class="signature-name">${userName ?? invoice.business?.name ?? "Best regards"}</div>
|
||||
${userEmail ? `<div class="signature-email">${userEmail}</div>` : ""}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<img src="${baseUrl}/beenvoice-logo.svg" alt="beenvoice" class="footer-logo" />
|
||||
${
|
||||
invoice.business
|
||||
? `<div class="footer-text">
|
||||
<strong>${invoice.business.name}</strong><br>
|
||||
${invoice.business.email ? `${invoice.business.email}<br>` : ""}
|
||||
${invoice.business.phone ? `${invoice.business.phone}<br>` : ""}
|
||||
${businessAddress ? `${businessAddress}` : ""}
|
||||
</div>`
|
||||
: `<div class="footer-text">
|
||||
Professional invoicing for modern businesses
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Generate plain text version
|
||||
const text = `
|
||||
${getTimeOfDayGreeting()},
|
||||
|
||||
I hope this email finds you well. Please find attached invoice #${invoice.invoiceNumber} for the services provided.${
|
||||
customMessage
|
||||
? `\n\n${customMessage
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}`
|
||||
: ""
|
||||
}${
|
||||
customContent
|
||||
? `\n\n${customContent
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}`
|
||||
: ""
|
||||
}
|
||||
|
||||
INVOICE DETAILS
|
||||
═══════════════
|
||||
Invoice Number: #${invoice.invoiceNumber}
|
||||
Issue Date: ${formatDate(invoice.issueDate)}
|
||||
Due Date: ${formatDate(invoice.dueDate)}
|
||||
Client: ${invoice.client.name}
|
||||
|
||||
AMOUNT BREAKDOWN
|
||||
═══════════════
|
||||
Subtotal: ${formatCurrency(subtotal)}${
|
||||
invoice.taxRate > 0
|
||||
? `\nTax (${invoice.taxRate}%): ${formatCurrency(taxAmount)}`
|
||||
: ""
|
||||
}
|
||||
Total: ${formatCurrency(total)}
|
||||
|
||||
|
||||
|
||||
ATTACHMENT
|
||||
═══════════════
|
||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||
|
||||
If you have any questions about this invoice, please don't hesitate to reach out.
|
||||
Thank you for your business!
|
||||
|
||||
${userName ?? invoice.business?.name ?? "Best regards"}${
|
||||
userEmail ? `\n${userEmail}` : ""
|
||||
}
|
||||
|
||||
---
|
||||
${
|
||||
invoice.business
|
||||
? `${invoice.business.name}${invoice.business.email ? `\n${invoice.business.email}` : ""}${
|
||||
invoice.business.phone ? `\n${invoice.business.phone}` : ""
|
||||
}${businessAddress ? `\n${businessAddress.replace(/<br>/g, "\n")}` : ""}`
|
||||
: "Professional invoicing for modern businesses"
|
||||
}
|
||||
`.trim();
|
||||
|
||||
return { html, text };
|
||||
}
|
||||
84
src/lib/email-utils.ts
Normal file
84
src/lib/email-utils.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { generateInvoiceEmailTemplate } from "./email-templates";
|
||||
|
||||
// Simple test utility to verify the email template works
|
||||
export function testEmailTemplate() {
|
||||
const mockInvoice = {
|
||||
invoiceNumber: "INV-001",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
status: "draft",
|
||||
totalAmount: 1000,
|
||||
taxRate: 8.5,
|
||||
notes: null,
|
||||
client: {
|
||||
name: "Test Client",
|
||||
email: "client@example.com",
|
||||
},
|
||||
business: {
|
||||
name: "Test Business",
|
||||
email: "business@example.com",
|
||||
phone: "(555) 123-4567",
|
||||
addressLine1: "123 Business St",
|
||||
addressLine2: null,
|
||||
city: "Business City",
|
||||
state: "CA",
|
||||
postalCode: "12345",
|
||||
country: "United States",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: new Date(),
|
||||
description: "Development Services",
|
||||
hours: 10,
|
||||
rate: 100,
|
||||
amount: 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const template = generateInvoiceEmailTemplate({
|
||||
invoice: mockInvoice,
|
||||
userName: "John Doe",
|
||||
userEmail: "john@example.com",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
hasHtml: !!template.html,
|
||||
hasText: !!template.text,
|
||||
htmlLength: template.html.length,
|
||||
textLength: template.text.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency for display
|
||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Format date for email display
|
||||
export function formatEmailDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Get time-based greeting
|
||||
export function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
@@ -305,7 +305,7 @@ export function formatWebsiteUrl(url: string): string {
|
||||
if (!url) return "";
|
||||
|
||||
// If URL doesn't start with http:// or https://, add https://
|
||||
if (!url.match(/^https?:\/\//i)) {
|
||||
if (!/^https?:\/\//i.exec(url)) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ export function formatWebsiteUrl(url: string): string {
|
||||
// Postal code formatting
|
||||
export function formatPostalCode(
|
||||
value: string,
|
||||
country: string = "United States",
|
||||
country = "United States",
|
||||
): string {
|
||||
if (country === "United States") {
|
||||
// Format as US ZIP code (12345 or 12345-6789)
|
||||
@@ -340,7 +340,7 @@ export function formatPostalCode(
|
||||
}
|
||||
|
||||
// Tax ID formatting
|
||||
export function formatTaxId(value: string, type: string = "EIN"): string {
|
||||
export function formatTaxId(value: string, type = "EIN"): string {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
|
||||
if (type === "EIN") {
|
||||
|
||||
137
src/lib/invoice-status.ts
Normal file
137
src/lib/invoice-status.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
StoredInvoiceStatus,
|
||||
EffectiveInvoiceStatus,
|
||||
} from "~/types/invoice";
|
||||
|
||||
// Types are now imported from ~/types/invoice
|
||||
|
||||
/**
|
||||
* Calculate the effective status of an invoice including overdue computation
|
||||
*/
|
||||
export function getEffectiveInvoiceStatus(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): EffectiveInvoiceStatus {
|
||||
// If already paid, status is always paid regardless of due date
|
||||
if (storedStatus === "paid") {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
// If draft, status is always draft
|
||||
if (storedStatus === "draft") {
|
||||
return "draft";
|
||||
}
|
||||
|
||||
// For sent invoices, check if overdue
|
||||
if (storedStatus === "sent") {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
// Set both dates to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
return due < today ? "overdue" : "sent";
|
||||
}
|
||||
|
||||
return storedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue
|
||||
*/
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): boolean {
|
||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days past due (returns 0 if not overdue)
|
||||
*/
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): number {
|
||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status configuration for UI display
|
||||
*/
|
||||
export const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
color: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
|
||||
description: "Invoice is being prepared",
|
||||
},
|
||||
sent: {
|
||||
label: "Sent",
|
||||
color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
|
||||
description: "Invoice sent to client",
|
||||
},
|
||||
paid: {
|
||||
label: "Paid",
|
||||
color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
description: "Payment received",
|
||||
},
|
||||
overdue: {
|
||||
label: "Overdue",
|
||||
color: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
|
||||
description: "Payment is overdue",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration for display
|
||||
*/
|
||||
export function getStatusConfig(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return statusConfig[effectiveStatus];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status transitions from current stored status
|
||||
*/
|
||||
export function getValidStatusTransitions(
|
||||
currentStatus: StoredInvoiceStatus,
|
||||
): StoredInvoiceStatus[] {
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
return ["sent", "paid"]; // Can send or mark paid directly
|
||||
case "sent":
|
||||
return ["paid", "draft"]; // Can mark paid or revert to draft
|
||||
case "paid":
|
||||
return ["sent"]; // Can revert to sent if needed (rare cases)
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(
|
||||
from: StoredInvoiceStatus,
|
||||
to: StoredInvoiceStatus,
|
||||
): boolean {
|
||||
const validTransitions = getValidStatusTransitions(from);
|
||||
return validTransitions.includes(to);
|
||||
}
|
||||
@@ -11,43 +11,68 @@ import {
|
||||
import { saveAs } from "file-saver";
|
||||
import React from "react";
|
||||
|
||||
// Register Inter font
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
src: "/fonts/inter/Inter-Variable.ttf",
|
||||
fontWeight: "normal",
|
||||
});
|
||||
// Global font registration state
|
||||
let fontsRegistered = false;
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
src: "/fonts/inter/Inter-Italic-Variable.ttf",
|
||||
fontStyle: "italic",
|
||||
});
|
||||
// Font registration helper that works in both client and server environments
|
||||
const registerFonts = () => {
|
||||
try {
|
||||
// Avoid duplicate registration
|
||||
if (fontsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register Azeret Mono fonts for numbers and tables - multiple weights
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "normal",
|
||||
});
|
||||
// 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: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "semibold",
|
||||
});
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
src: "/fonts/inter/Inter-Italic-Variable.ttf",
|
||||
fontStyle: "italic",
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: "AzeretMono",
|
||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
// 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-Italic-Variable.ttf",
|
||||
fontStyle: "italic",
|
||||
});
|
||||
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 (error) {
|
||||
console.warn("Font registration failed, using built-in fonts:", error);
|
||||
fontsRegistered = true; // Don't keep trying
|
||||
}
|
||||
};
|
||||
|
||||
// Register fonts immediately
|
||||
registerFonts();
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
@@ -94,7 +119,7 @@ const styles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#ffffff",
|
||||
fontFamily: "Inter",
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: 10,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 80,
|
||||
@@ -121,16 +146,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
businessName: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontSize: 18,
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
businessInfo: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#6b7280",
|
||||
marginBottom: 2,
|
||||
lineHeight: 1.3,
|
||||
marginBottom: 3,
|
||||
},
|
||||
|
||||
businessAddress: {
|
||||
@@ -147,15 +174,14 @@ const styles = StyleSheet.create({
|
||||
|
||||
invoiceTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#10b981",
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
invoiceNumber: {
|
||||
fontSize: 15,
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "AzeretMono",
|
||||
fontFamily: "Courier-Bold",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
@@ -165,7 +191,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
textAlign: "center",
|
||||
},
|
||||
|
||||
@@ -193,21 +219,23 @@ const styles = StyleSheet.create({
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
clientName: {
|
||||
fontSize: 13,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontSize: 14,
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
||||
clientInfo: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.3,
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
||||
@@ -232,9 +260,8 @@ const styles = StyleSheet.create({
|
||||
|
||||
detailValue: {
|
||||
fontSize: 10,
|
||||
fontFamily: "AzeretMono",
|
||||
fontFamily: "Courier-Bold",
|
||||
color: "#111827",
|
||||
fontWeight: "semibold",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
@@ -251,17 +278,25 @@ const styles = StyleSheet.create({
|
||||
|
||||
notesTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
marginBottom: 6,
|
||||
},
|
||||
|
||||
notesContent: {
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
|
||||
businessContact: {
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
|
||||
// Separator styles
|
||||
headerSeparator: {
|
||||
height: 1,
|
||||
@@ -280,8 +315,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
abridgedBusinessName: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
fontSize: 12,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
},
|
||||
|
||||
@@ -293,14 +328,13 @@ const styles = StyleSheet.create({
|
||||
|
||||
abridgedInvoiceTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#10b981",
|
||||
},
|
||||
|
||||
abridgedInvoiceNumber: {
|
||||
fontSize: 13,
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "AzeretMono",
|
||||
fontFamily: "Courier-Bold",
|
||||
color: "#111827",
|
||||
},
|
||||
|
||||
@@ -320,7 +354,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
tableHeaderCell: {
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
@@ -369,8 +403,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
tableCellDate: {
|
||||
width: "15%",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "Courier",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
@@ -383,24 +416,21 @@ const styles = StyleSheet.create({
|
||||
tableCellHours: {
|
||||
width: "12%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "Courier",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellRate: {
|
||||
width: "15%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "semibold",
|
||||
fontFamily: "Courier",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
tableCellAmount: {
|
||||
width: "18%",
|
||||
textAlign: "right",
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Courier-Bold",
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
|
||||
@@ -432,9 +462,8 @@ const styles = StyleSheet.create({
|
||||
|
||||
totalAmount: {
|
||||
fontSize: 10,
|
||||
fontFamily: "AzeretMono",
|
||||
fontFamily: "Courier-Bold",
|
||||
color: "#111827",
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
|
||||
finalTotalRow: {
|
||||
@@ -447,19 +476,19 @@ const styles = StyleSheet.create({
|
||||
|
||||
finalTotalLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1f2937",
|
||||
},
|
||||
|
||||
finalTotalAmount: {
|
||||
fontSize: 15,
|
||||
fontFamily: "AzeretMono",
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Courier-Bold",
|
||||
color: "#10b981",
|
||||
},
|
||||
|
||||
itemCount: {
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
marginTop: 6,
|
||||
@@ -757,6 +786,7 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
const Footer: React.FC = () => (
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={styles.footerLogo}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image
|
||||
src="/beenvoice-logo.png"
|
||||
style={{
|
||||
@@ -892,6 +922,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||
// Export functions
|
||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
try {
|
||||
// Ensure fonts are registered
|
||||
registerFonts();
|
||||
|
||||
// Validate invoice data
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice data is required");
|
||||
@@ -935,6 +968,11 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +984,12 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||
export async function generateInvoicePDFBlob(
|
||||
invoice: InvoiceData,
|
||||
): Promise<Blob> {
|
||||
const isServerSide = typeof window === "undefined";
|
||||
|
||||
try {
|
||||
// Ensure fonts are registered (important for server-side generation)
|
||||
registerFonts();
|
||||
|
||||
// Validate invoice data
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice data is required");
|
||||
@@ -960,17 +1003,56 @@ export async function generateInvoicePDFBlob(
|
||||
throw new Error("Client information is required");
|
||||
}
|
||||
|
||||
// Generate PDF blob
|
||||
const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||
console.log(
|
||||
`Generating PDF blob for invoice ${invoice.invoiceNumber} (${isServerSide ? "server-side" : "client-side"})...`,
|
||||
);
|
||||
|
||||
// Generate PDF blob with timeout (same as generateInvoicePDF)
|
||||
const pdfPromise = 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
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Generated PDF is empty");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`PDF blob generated successfully, size: ${blob.size} bytes (${isServerSide ? "server-side" : "client-side"})`,
|
||||
);
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
throw new Error("Failed to generate PDF");
|
||||
console.error(
|
||||
`PDF generation error for email attachment (${isServerSide ? "server-side" : "client-side"}):`,
|
||||
error,
|
||||
);
|
||||
|
||||
// Provide more specific error messages (same as generateInvoicePDF)
|
||||
if (error instanceof 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.");
|
||||
} 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 for email attachment: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Pluralization rules for common entities in the app
|
||||
*/
|
||||
const PLURALIZATION_RULES: Record<string, { singular: string; plural: string }> = {
|
||||
const PLURALIZATION_RULES: Record<
|
||||
string,
|
||||
{ singular: string; plural: string }
|
||||
> = {
|
||||
business: { singular: "Business", plural: "Businesses" },
|
||||
client: { singular: "Client", plural: "Clients" },
|
||||
invoice: { singular: "Invoice", plural: "Invoices" },
|
||||
@@ -58,7 +61,7 @@ export function singularize(word: string): string {
|
||||
|
||||
// Check if we have a specific rule for this word (search by plural)
|
||||
const rule = Object.values(PLURALIZATION_RULES).find(
|
||||
(r) => r.plural.toLowerCase() === lowerWord
|
||||
(r) => r.plural.toLowerCase() === lowerWord,
|
||||
);
|
||||
|
||||
if (rule) {
|
||||
@@ -101,7 +104,7 @@ export function capitalize(word: string): string {
|
||||
/**
|
||||
* Get a properly formatted label for a route segment
|
||||
*/
|
||||
export function getRouteLabel(segment: string, isPlural: boolean = true): string {
|
||||
export function getRouteLabel(segment: string, isPlural = true): string {
|
||||
// First, check if it's already in our rules
|
||||
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
||||
if (rule) {
|
||||
|
||||
Reference in New Issue
Block a user