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:
Gerhard Scheikl
2026-05-08 23:06:40 +02:00
parent 537dfd34cb
commit 04933fcac6
8 changed files with 1120 additions and 11 deletions
+110 -5
View File
@@ -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 {
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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 {};
}
}