feat(email): WYSIWYG template editor with variable substitution
- Add emailSubject{De,En} + emailBodyHtml{De,En} to ShopSettings
- New RichTextEditor component (TipTap) with toolbar + variable insert
- Settings UI: Email templates section per language
- email.server.ts: substitute {{var}} placeholders, fall back to defaults
- Default vars: invoiceNumber, customerName, customerFirstName, orderName,
totalGross, dueDate, companyName, ownerName
This commit is contained in:
@@ -63,14 +63,31 @@ export async function sendInvoiceEmail(
|
||||
// Build email content.
|
||||
const language = pickLanguage(args.customerLocale ?? settings.defaultLanguage);
|
||||
const t = getStrings(language);
|
||||
const subject = `${t.invoice} ${invoice.invoiceNumber}` +
|
||||
(settings.companyName ? ` — ${settings.companyName}` : "");
|
||||
const body = renderEmailBody({
|
||||
const customer = parseCustomer(invoice.customerJson);
|
||||
const totals = parseTotals(invoice.totalsJson);
|
||||
const vars = buildTemplateVars({
|
||||
invoice,
|
||||
settings,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
language,
|
||||
customerName: customer.customerName ?? "",
|
||||
customerFirstName: (customer.customerName ?? "").split(/\s+/)[0] ?? "",
|
||||
totalGross: totals.totalGross ?? "",
|
||||
});
|
||||
|
||||
const customSubject = language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe;
|
||||
const subject = customSubject
|
||||
? renderTemplate(customSubject, vars)
|
||||
: `${t.invoice} ${invoice.invoiceNumber}` +
|
||||
(settings.companyName ? ` — ${settings.companyName}` : "");
|
||||
|
||||
const customBodyHtml = language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe;
|
||||
const body = customBodyHtml
|
||||
? renderHtmlBody(renderTemplate(customBodyHtml, vars))
|
||||
: renderEmailBody({
|
||||
settings,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
language,
|
||||
});
|
||||
|
||||
// Download the PDF (Shopify Files URLs are public CDN URLs).
|
||||
let pdfBytes: Uint8Array;
|
||||
try {
|
||||
@@ -203,3 +220,91 @@ function escapeHtml(s: string): string {
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Template variables ----------------------------------------------------
|
||||
|
||||
interface TemplateVars {
|
||||
invoiceNumber: string;
|
||||
customerName: string;
|
||||
customerFirstName: string;
|
||||
orderName: string;
|
||||
totalGross: string;
|
||||
dueDate: string;
|
||||
companyName: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
function buildTemplateVars(args: {
|
||||
invoice: { invoiceNumber: string; orderName: string };
|
||||
settings: ShopSettings;
|
||||
customerName: string;
|
||||
customerFirstName: string;
|
||||
totalGross: string;
|
||||
}): TemplateVars {
|
||||
const dueMs = Number((args.invoice as unknown as { dueDate?: string | Date }).dueDate ?? 0);
|
||||
return {
|
||||
invoiceNumber: args.invoice.invoiceNumber,
|
||||
orderName: args.invoice.orderName,
|
||||
customerName: args.customerName,
|
||||
customerFirstName: args.customerFirstName,
|
||||
totalGross: args.totalGross,
|
||||
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
|
||||
companyName: args.settings.companyName,
|
||||
ownerName: args.settings.ownerName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
||||
* in place so the user notices typos instead of silent blanks. Values are
|
||||
* inserted verbatim — callers are responsible for HTML-escaping if needed.
|
||||
*/
|
||||
function renderTemplate(template: string, vars: TemplateVars): string {
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
||||
const v = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
return v === undefined ? full : v;
|
||||
});
|
||||
}
|
||||
|
||||
/** Strips HTML tags to produce a plain-text fallback for the multipart email. */
|
||||
function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>(?=\s|$)/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderHtmlBody(html: string): { text: string; html: string } {
|
||||
return { html, text: htmlToText(html) };
|
||||
}
|
||||
|
||||
interface InvoiceCustomerSnapshot {
|
||||
customerEmail?: string;
|
||||
customerName?: string;
|
||||
}
|
||||
function parseCustomer(json: string): InvoiceCustomerSnapshot {
|
||||
try {
|
||||
return JSON.parse(json) as InvoiceCustomerSnapshot;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface InvoiceTotalsSnapshot {
|
||||
totalGross?: string;
|
||||
}
|
||||
function parseTotals(json: string): InvoiceTotalsSnapshot {
|
||||
try {
|
||||
return JSON.parse(json) as InvoiceTotalsSnapshot;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user