UNPKG

invoice-craft

Version:

Customizable, browser-first invoice PDF generator library with modern TypeScript API

331 lines (324 loc) 15.9 kB
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]; }