invoice-craft
Version:
Customizable, browser-first invoice PDF generator library with modern TypeScript API
331 lines (324 loc) • 15.9 kB
JavaScript
import { calculateTotals } from "../core/calculations";
import { getLabels } from "../utils/localization";
export class TemplateBuilder {
constructor(id, name) {
this.template = {};
this.template.id = id;
this.template.name = name;
}
setDescription(description) {
this.template.description = description;
return this;
}
setHeader(content, styles) {
this.template.header = { content, styles };
return this;
}
setBody(content, styles) {
this.template.body = { content, styles };
return this;
}
setFooter(content, styles) {
this.template.footer = { content, styles };
return this;
}
setGlobalStyles(styles) {
this.template.styles = styles;
return this;
}
setSupportedFeatures(features) {
this.template.supportedFeatures = features;
return this;
}
build() {
if (!this.template.id || !this.template.name) {
throw new Error('Template must have an id and name');
}
return {
id: this.template.id,
name: this.template.name,
description: this.template.description || '',
header: this.template.header || { content: '' },
body: this.template.body || { content: '' },
footer: this.template.footer || { content: '' },
styles: this.template.styles || {},
supportedFeatures: this.template.supportedFeatures || {
logo: true,
brandColor: true,
rtl: false,
extraSections: true
}
};
}
}
export function createTemplate(id, name) {
return new TemplateBuilder(id, name);
}
export function renderTemplate(template, invoice, options = {}) {
const totals = calculateTotals(invoice);
const labels = getLabels(options.labels);
const brandColor = options.brandColor || invoice.from.brandColor || "#1976d2";
const templateData = {
invoice,
totals,
labels,
brandColor,
options
};
const renderedHeader = renderSection(template.header, templateData);
const renderedBody = renderSection(template.body, templateData);
const renderedFooter = renderSection(template.footer, templateData);
return {
header: renderedHeader,
body: renderedBody,
footer: renderedFooter,
styles: template.styles
};
}
function renderSection(section, data) {
if (typeof section.content === 'function') {
return section.content(data);
}
return section.content;
}
// Pre-built templates
export const modernTemplate = createTemplate('modern', 'Modern Professional')
.setDescription('Clean, modern design with bold typography and subtle colors')
.setHeader((data) => `
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid ${data.brandColor}; padding-bottom: 20px; margin-bottom: 30px;">
<div>
${data.invoice.from.logoUrl ? `<img src="${data.invoice.from.logoUrl}" style="max-height: 50px; margin-bottom: 10px;">` : ''}
<h1 style="color: ${data.brandColor}; font-size: 2.5em; margin: 0;">${data.invoice.from.name}</h1>
<p style="color: #666; margin: 5px 0;">${data.invoice.from.address}</p>
</div>
<div style="text-align: right;">
<h2 style="color: ${data.brandColor}; font-size: 2em; margin: 0;">${data.labels.invoice}</h2>
<p style="font-size: 1.2em; margin: 5px 0;"><strong>${data.labels.invoiceNumber}:</strong> ${data.invoice.invoiceNumber}</p>
<p style="margin: 5px 0;"><strong>${data.labels.date}:</strong> ${data.invoice.invoiceDate}</p>
</div>
</div>
`)
.setBody((data) => `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 30px;">
<div>
<h3 style="color: ${data.brandColor}; border-bottom: 2px solid ${data.brandColor}; padding-bottom: 5px;">${data.labels.billTo}</h3>
<p style="margin: 10px 0;"><strong>${data.invoice.to.name}</strong></p>
<p style="margin: 5px 0;">${data.invoice.to.address}</p>
</div>
<div>
<h3 style="color: ${data.brandColor}; border-bottom: 2px solid ${data.brandColor}; padding-bottom: 5px;">${data.labels.billFrom}</h3>
<p style="margin: 10px 0;"><strong>${data.invoice.from.name}</strong></p>
<p style="margin: 5px 0;">${data.invoice.from.address}</p>
</div>
</div>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
<thead>
<tr style="background-color: ${data.brandColor}; color: white;">
<th style="padding: 15px; text-align: left;">${data.labels.description}</th>
<th style="padding: 15px; text-align: right;">${data.labels.quantity}</th>
<th style="padding: 15px; text-align: right;">${data.labels.unitPrice}</th>
<th style="padding: 15px; text-align: right;">${data.labels.total}</th>
</tr>
</thead>
<tbody>
${data.invoice.items.map((item) => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 15px;">${item.description}</td>
<td style="padding: 15px; text-align: right;">${item.quantity}</td>
<td style="padding: 15px; text-align: right;">$${item.unitPrice.toFixed(2)}</td>
<td style="padding: 15px; text-align: right;">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style="margin-left: auto; width: 300px; background: #f9f9f9; padding: 20px; border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>${data.labels.subtotal}:</span>
<span>$${data.totals.subtotal.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0; border-top: 2px solid ${data.brandColor}; padding-top: 10px; font-weight: bold; color: ${data.brandColor};">
<span>${data.labels.total}:</span>
<span>$${data.totals.total.toFixed(2)}</span>
</div>
</div>
`)
.setFooter((data) => `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; text-align: center; color: #666;">
${data.invoice.terms ? `<p style="margin-bottom: 10px;"><strong>Terms:</strong> ${data.invoice.terms}</p>` : ''}
${data.invoice.notes ? `<p><strong>Notes:</strong> ${data.invoice.notes}</p>` : ''}
<p style="margin-top: 20px; font-size: 0.9em;">Thank you for your business!</p>
</div>
`)
.setSupportedFeatures({
logo: true,
brandColor: true,
rtl: false,
extraSections: true
})
.build();
export const minimalTemplate = createTemplate('minimal', 'Minimal Clean')
.setDescription('Ultra-clean minimal design with maximum readability')
.setHeader((data) => `
<div style="margin-bottom: 40px;">
<h1 style="font-size: 3em; font-weight: 300; margin: 0; color: #333;">${data.labels.invoice}</h1>
<div style="display: flex; justify-content: space-between; margin-top: 20px;">
<div>
<p style="margin: 2px 0; color: #666;"><strong>${data.labels.invoiceNumber}:</strong> ${data.invoice.invoiceNumber}</p>
<p style="margin: 2px 0; color: #666;"><strong>${data.labels.date}:</strong> ${data.invoice.invoiceDate}</p>
</div>
<div style="text-align: right;">
<p style="margin: 2px 0; font-size: 1.2em;"><strong>${data.invoice.from.name}</strong></p>
<p style="margin: 2px 0; color: #666;">${data.invoice.from.address}</p>
</div>
</div>
</div>
`)
.setBody((data) => `
<div style="margin-bottom: 30px;">
<p style="margin-bottom: 10px; color: #666;"><strong>${data.labels.billTo}:</strong></p>
<p style="margin: 2px 0; font-size: 1.1em;"><strong>${data.invoice.to.name}</strong></p>
<p style="margin: 2px 0; color: #666;">${data.invoice.to.address}</p>
</div>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
<thead>
<tr style="border-bottom: 2px solid #333;">
<th style="padding: 10px 0; text-align: left; font-weight: 600;">${data.labels.description}</th>
<th style="padding: 10px 0; text-align: right; font-weight: 600;">${data.labels.quantity}</th>
<th style="padding: 10px 0; text-align: right; font-weight: 600;">${data.labels.unitPrice}</th>
<th style="padding: 10px 0; text-align: right; font-weight: 600;">${data.labels.total}</th>
</tr>
</thead>
<tbody>
${data.invoice.items.map((item) => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 15px 0;">${item.description}</td>
<td style="padding: 15px 0; text-align: right;">${item.quantity}</td>
<td style="padding: 15px 0; text-align: right;">$${item.unitPrice.toFixed(2)}</td>
<td style="padding: 15px 0; text-align: right;">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style="text-align: right;">
<p style="margin: 5px 0; font-size: 1.2em;"><strong>${data.labels.total}: $${data.totals.total.toFixed(2)}</strong></p>
</div>
`)
.setFooter((data) => `
${data.invoice.terms || data.invoice.notes ? `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee;">
${data.invoice.terms ? `<p style="margin-bottom: 10px; color: #666;"><strong>Terms:</strong> ${data.invoice.terms}</p>` : ''}
${data.invoice.notes ? `<p style="color: #666;"><strong>Notes:</strong> ${data.invoice.notes}</p>` : ''}
</div>
` : ''}
`)
.setSupportedFeatures({
logo: false,
brandColor: false,
rtl: false,
extraSections: false
})
.build();
export const creativeTemplate = createTemplate('creative', 'Creative Design')
.setDescription('Bold, creative design with unique layout and typography')
.setHeader((data) => `
<div style="background: linear-gradient(135deg, ${data.brandColor}, ${adjustColor(data.brandColor, -20)}); color: white; padding: 30px; margin: -40px -40px 40px -40px; border-radius: 0;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
${data.invoice.from.logoUrl ? `<img src="${data.invoice.from.logoUrl}" style="max-height: 60px; margin-bottom: 15px; filter: brightness(0) invert(1);">` : ''}
<h1 style="font-size: 2.8em; margin: 0; font-weight: 700; text-shadow: 2px 2px 4px rgba(0,0,0,0.3);">${data.invoice.from.name}</h1>
<p style="margin: 5px 0; opacity: 0.9;">${data.invoice.from.address}</p>
</div>
<div style="text-align: right; background: rgba(255,255,255,0.1); padding: 20px; border-radius: 10px;">
<h2 style="font-size: 2.2em; margin: 0; font-weight: 300;">${data.labels.invoice}</h2>
<p style="font-size: 1.3em; margin: 5px 0;"><strong>#${data.invoice.invoiceNumber}</strong></p>
<p style="margin: 5px 0; opacity: 0.9;">${data.invoice.invoiceDate}</p>
</div>
</div>
</div>
`)
.setBody((data) => `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 40px;">
<div style="background: #f8f9fa; padding: 25px; border-radius: 15px; border-left: 5px solid ${data.brandColor};">
<h3 style="color: ${data.brandColor}; margin: 0 0 15px 0; font-size: 1.4em;">${data.labels.billTo}</h3>
<p style="margin: 8px 0; font-size: 1.1em;"><strong>${data.invoice.to.name}</strong></p>
<p style="margin: 5px 0; color: #666;">${data.invoice.to.address}</p>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 15px; border-left: 5px solid ${data.brandColor};">
<h3 style="color: ${data.brandColor}; margin: 0 0 15px 0; font-size: 1.4em;">${data.labels.billFrom}</h3>
<p style="margin: 8px 0; font-size: 1.1em;"><strong>${data.invoice.from.name}</strong></p>
<p style="margin: 5px 0; color: #666;">${data.invoice.from.address}</p>
</div>
</div>
<div style="background: white; border-radius: 15px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: ${data.brandColor}; color: white;">
<th style="padding: 20px; text-align: left; font-size: 1.1em;">${data.labels.description}</th>
<th style="padding: 20px; text-align: right; font-size: 1.1em;">${data.labels.quantity}</th>
<th style="padding: 20px; text-align: right; font-size: 1.1em;">${data.labels.unitPrice}</th>
<th style="padding: 20px; text-align: right; font-size: 1.1em;">${data.labels.total}</th>
</tr>
</thead>
<tbody>
${data.invoice.items.map((item, index) => `
<tr style="background: ${index % 2 === 0 ? '#f8f9fa' : 'white'};">
<td style="padding: 18px; font-weight: 500;">${item.description}</td>
<td style="padding: 18px; text-align: right;">${item.quantity}</td>
<td style="padding: 18px; text-align: right;">$${item.unitPrice.toFixed(2)}</td>
<td style="padding: 18px; text-align: right; font-weight: 600;">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div style="margin: 30px 0; text-align: right;">
<div style="display: inline-block; background: ${data.brandColor}; color: white; padding: 25px; border-radius: 15px; min-width: 300px;">
<div style="display: flex; justify-content: space-between; margin: 8px 0; font-size: 1.1em;">
<span>${data.labels.subtotal}:</span>
<span>$${data.totals.subtotal.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 15px 0 0 0; padding-top: 15px; border-top: 2px solid rgba(255,255,255,0.3); font-size: 1.4em; font-weight: bold;">
<span>${data.labels.total}:</span>
<span>$${data.totals.total.toFixed(2)}</span>
</div>
</div>
</div>
`)
.setFooter((data) => `
<div style="margin-top: 50px; text-align: center; padding: 25px; background: #f8f9fa; border-radius: 15px;">
${data.invoice.terms ? `<p style="margin-bottom: 15px; color: #666;"><strong>Terms & Conditions:</strong> ${data.invoice.terms}</p>` : ''}
${data.invoice.notes ? `<p style="margin-bottom: 15px; color: #666;"><strong>Additional Notes:</strong> ${data.invoice.notes}</p>` : ''}
<p style="margin: 0; color: ${data.brandColor}; font-weight: 600; font-size: 1.1em;">Thank you for choosing ${data.invoice.from.name}!</p>
</div>
`)
.setSupportedFeatures({
logo: true,
brandColor: true,
rtl: false,
extraSections: true
})
.build();
// Helper function to adjust color brightness
function adjustColor(color, amount) {
const usePound = color[0] === '#';
const col = usePound ? color.slice(1) : color;
const num = parseInt(col, 16);
let r = (num >> 16) + amount;
let g = (num >> 8 & 0x00FF) + amount;
let b = (num & 0x0000FF) + amount;
r = r > 255 ? 255 : r < 0 ? 0 : r;
g = g > 255 ? 255 : g < 0 ? 0 : g;
b = b > 255 ? 255 : b < 0 ? 0 : b;
return (usePound ? '#' : '') + (r << 16 | g << 8 | b).toString(16).padStart(6, '0');
}
export const availableTemplates = [
modernTemplate,
minimalTemplate,
creativeTemplate
];
export function getTemplateById(id) {
return availableTemplates.find(template => template.id === id);
}
export function getAllTemplates() {
return [...availableTemplates];
}