/** * Server-side HTML email template renderers for receipts and repair estimates. * Uses inline CSS for email-client compatibility. */ interface Address { street?: string city?: string state?: string zip?: string } interface CompanyInfo { name: string phone?: string | null email?: string | null address?: Address | null } interface ReceiptLineItem { description: string qty: number unitPrice: string taxAmount: string lineTotal: string discountAmount?: string | null } interface ReceiptTransaction { transactionNumber: string subtotal: string discountTotal: string taxTotal: string total: string paymentMethod: string | null amountTendered: string | null changeGiven: string | null roundingAdjustment: string | null completedAt: string | null lineItems: ReceiptLineItem[] } interface ReceiptConfig { receipt_header?: string receipt_footer?: string receipt_return_policy?: string receipt_social?: string } interface ReceiptData { transaction: ReceiptTransaction company: CompanyInfo | null location: CompanyInfo | null } interface RepairLineItem { itemType: string description: string qty: number unitPrice: string totalPrice: string } interface RepairTicket { ticketNumber: string customerName: string customerPhone?: string | null itemDescription?: string | null serialNumber?: string | null problemDescription?: string | null estimatedCost?: string | null promisedDate?: string | null status: string } // ── Shared layout ────────────────────────────────────────────────────────── function wrapEmailLayout(storeName: string, bodyHtml: string): string { return `

${esc(storeName)}

${bodyHtml}

Powered by LunarFront

` } function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function formatAddress(addr?: Address | null): string { if (!addr) return '' const parts = [addr.street, [addr.city, addr.state].filter(Boolean).join(', '), addr.zip].filter(Boolean) return parts.join('
') } function formatDate(dateStr: string | null): string { if (!dateStr) return '' const d = new Date(dateStr) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) } function formatDateOnly(dateStr: string | null): string { if (!dateStr) return '' const d = new Date(dateStr) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } const PAYMENT_LABELS: Record = { cash: 'Cash', card_present: 'Card', card_keyed: 'Card (Keyed)', check: 'Check', store_credit: 'Store Credit', other: 'Other', } const TYPE_LABELS: Record = { labor: 'Labor', part: 'Part', flat_rate: 'Flat Rate', misc: 'Misc', consumable: 'Consumable', } // ── Receipt email ────────────────────────────────────────────────────────── export function renderReceiptEmailHtml(data: ReceiptData, config?: ReceiptConfig): string { const txn = data.transaction const storeName = data.company?.name ?? 'Store' let companyBlock = '' if (data.company) { const addr = formatAddress(data.company.address) companyBlock = `
${addr ? `
${addr}
` : ''} ${data.company.phone ? `
${esc(data.company.phone)}
` : ''}
` } const headerText = config?.receipt_header ? `

${esc(config.receipt_header)}

` : '' const lineRows = txn.lineItems.map(item => ` ${esc(item.description)} ${item.qty} $${item.unitPrice} $${item.lineTotal} `).join('') const discountRow = parseFloat(txn.discountTotal) > 0 ? `Discount-$${txn.discountTotal}` : '' const roundingRow = txn.roundingAdjustment && parseFloat(txn.roundingAdjustment) !== 0 ? `Rounding$${txn.roundingAdjustment}` : '' const paymentBlock = txn.paymentMethod ? `
Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}
${txn.amountTendered && txn.paymentMethod === 'cash' ? `
Tendered: $${txn.amountTendered}
` : ''} ${txn.changeGiven && parseFloat(txn.changeGiven) > 0 ? `
Change: $${txn.changeGiven}
` : ''}
` : '' const footerText = config?.receipt_footer ? `

${esc(config.receipt_footer)}

` : '' const policyText = config?.receipt_return_policy ? `

${esc(config.receipt_return_policy)}

` : '' const socialText = config?.receipt_social ? `

${esc(config.receipt_social)}

` : '' const body = ` ${companyBlock} ${headerText}
Receipt #${esc(txn.transactionNumber)}
${formatDate(txn.completedAt)}
${lineRows}
Item Qty Price Total
${discountRow} ${roundingRow}
Subtotal$${txn.subtotal}
Tax$${txn.taxTotal}
Total$${txn.total}
${paymentBlock} ${footerText} ${policyText} ${socialText} ` return wrapEmailLayout(storeName, body) } export function renderReceiptEmailText(data: ReceiptData, config?: ReceiptConfig): string { const txn = data.transaction const storeName = data.company?.name ?? 'Store' const lines = [ storeName, `Receipt #${txn.transactionNumber}`, formatDate(txn.completedAt), '', ...txn.lineItems.map(i => `${i.description} x${i.qty} — $${i.lineTotal}`), '', `Subtotal: $${txn.subtotal}`, ...(parseFloat(txn.discountTotal) > 0 ? [`Discount: -$${txn.discountTotal}`] : []), `Tax: $${txn.taxTotal}`, `Total: $${txn.total}`, '', ...(txn.paymentMethod ? [`Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}`] : []), ...(config?.receipt_footer ? ['', config.receipt_footer] : []), ] return lines.join('\n') } // ── Repair estimate email ────────────────────────────────────────────────── export function renderEstimateEmailHtml(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string { const storeName = company.name const lineRows = lineItems.map(item => ` ${TYPE_LABELS[item.itemType] ?? item.itemType} ${esc(item.description)} ${item.qty} $${item.unitPrice} $${item.totalPrice} `).join('') const estimatedTotal = ticket.estimatedCost ? `Estimated Total$${ticket.estimatedCost}` : '' const lineItemTotal = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0).toFixed(2) const lineItemTotalRow = !ticket.estimatedCost && lineItems.length > 0 ? `Estimated Total$${lineItemTotal}` : '' const body = `
Repair Estimate — Ticket #${esc(ticket.ticketNumber)}
${esc(ticket.customerName)}
${ticket.itemDescription ? `
Item: ${esc(ticket.itemDescription)}${ticket.serialNumber ? ` (S/N: ${esc(ticket.serialNumber)})` : ''}
` : ''} ${ticket.problemDescription ? `
Issue: ${esc(ticket.problemDescription)}
` : ''} ${ticket.promisedDate ? `
Estimated completion: ${formatDateOnly(ticket.promisedDate)}
` : ''}
${lineItems.length > 0 ? ` ${lineRows} ${estimatedTotal} ${lineItemTotalRow}
Type Description Qty Price Total
` : ` ${ticket.estimatedCost ? `
Estimated Cost: $${ticket.estimatedCost}
` : ''} `}

This is an estimate and the final cost may vary. Please contact us with any questions.

${company.phone ? `

Phone: ${esc(company.phone)}

` : ''} ` return wrapEmailLayout(storeName, body) } export function renderEstimateEmailText(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string { const lines = [ company.name, `Repair Estimate — Ticket #${ticket.ticketNumber}`, `Customer: ${ticket.customerName}`, '', ...(ticket.itemDescription ? [`Item: ${ticket.itemDescription}${ticket.serialNumber ? ` (S/N: ${ticket.serialNumber})` : ''}`] : []), ...(ticket.problemDescription ? [`Issue: ${ticket.problemDescription}`] : []), ...(ticket.promisedDate ? [`Estimated completion: ${formatDateOnly(ticket.promisedDate)}`] : []), '', ...lineItems.map(i => `${TYPE_LABELS[i.itemType] ?? i.itemType}: ${i.description} x${i.qty} — $${i.totalPrice}`), '', ...(ticket.estimatedCost ? [`Estimated Total: $${ticket.estimatedCost}`] : []), '', 'This is an estimate and the final cost may vary.', ...(company.phone ? [`Phone: ${company.phone}`] : []), ] return lines.join('\n') }