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:
@@ -0,0 +1,253 @@
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
/** Hidden form field name; the rendered HTML is mirrored into it. */
|
||||
name: string;
|
||||
/** Visible label rendered above the editor. */
|
||||
label: string;
|
||||
/** Initial HTML loaded into the editor. */
|
||||
defaultValue?: string;
|
||||
/** Optional helper text shown below the editor. */
|
||||
helpText?: string;
|
||||
/** Insertable variable tokens shown as quick-insert buttons. */
|
||||
variables?: { token: string; label?: string }[];
|
||||
/** Min editor height in px. */
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TipTap-based WYSIWYG editor that mirrors its HTML content into a hidden
|
||||
* input so the parent <Form> can submit it like any other field.
|
||||
*
|
||||
* Variables like {{invoiceNumber}} are inserted as plain text — the email
|
||||
* renderer is responsible for substituting them at send time.
|
||||
*/
|
||||
export function RichTextEditor({
|
||||
name,
|
||||
label,
|
||||
defaultValue = "",
|
||||
helpText,
|
||||
variables = [],
|
||||
minHeight = 200,
|
||||
}: RichTextEditorProps) {
|
||||
// TipTap calls into the DOM on init; defer mounting until after hydration
|
||||
// so SSR markup matches the initial client render.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Drop heading levels we don't need to keep the toolbar focused.
|
||||
heading: { levels: [2, 3] },
|
||||
}),
|
||||
Link.configure({ openOnClick: false }),
|
||||
],
|
||||
content: defaultValue || "<p></p>",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "wysiwyg-editor",
|
||||
style: `min-height:${minHeight}px;padding:8px 12px;border:1px solid #c9cccf;border-top:0;border-radius:0 0 6px 6px;background:#fff;outline:none;`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Keep editor disposed cleanly on unmount.
|
||||
useEffect(() => () => editor?.destroy(), [editor]);
|
||||
|
||||
const html = editor?.getHTML() ?? defaultValue;
|
||||
|
||||
if (!mounted) {
|
||||
// Server / pre-hydration fallback: a textarea so the value is still
|
||||
// submittable if JS fails or before TipTap mounts.
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<textarea
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
style={{ width: "100%", minHeight, padding: 8, border: "1px solid #c9cccf", borderRadius: 6 }}
|
||||
/>
|
||||
{helpText ? (
|
||||
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
role="toolbar"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: "6px 6px 0 0",
|
||||
background: "#f6f6f7",
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
active={editor?.isActive("bold") ?? false}
|
||||
label="B"
|
||||
title="Bold"
|
||||
bold
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
active={editor?.isActive("italic") ?? false}
|
||||
label="I"
|
||||
title="Italic"
|
||||
italic
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
active={editor?.isActive("strike") ?? false}
|
||||
label="S"
|
||||
title="Strikethrough"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
active={editor?.isActive("paragraph") ?? false}
|
||||
label="¶"
|
||||
title="Paragraph"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor?.isActive("heading", { level: 2 }) ?? false}
|
||||
label="H2"
|
||||
title="Heading 2"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor?.isActive("heading", { level: 3 }) ?? false}
|
||||
label="H3"
|
||||
title="Heading 3"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
active={editor?.isActive("bulletList") ?? false}
|
||||
label="• List"
|
||||
title="Bullet list"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
active={editor?.isActive("orderedList") ?? false}
|
||||
label="1. List"
|
||||
title="Numbered list"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
const url = window.prompt("URL");
|
||||
if (url) editor?.chain().focus().setLink({ href: url }).run();
|
||||
else editor?.chain().focus().unsetLink().run();
|
||||
}}
|
||||
active={editor?.isActive("link") ?? false}
|
||||
label="Link"
|
||||
title="Insert/remove link"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
active={false}
|
||||
label="↺"
|
||||
title="Undo"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
active={false}
|
||||
label="↻"
|
||||
title="Redo"
|
||||
/>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
<input type="hidden" name={name} value={html} />
|
||||
{variables.length > 0 ? (
|
||||
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, color: "#6d7175", marginRight: 4 }}>Insert variable:</span>
|
||||
{variables.map((v) => (
|
||||
<button
|
||||
key={v.token}
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().insertContent(v.token).run()}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: 12,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={`Inserts ${v.token}`}
|
||||
>
|
||||
{v.label ?? v.token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{helpText ? (
|
||||
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
label,
|
||||
title,
|
||||
bold,
|
||||
italic,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
label: string;
|
||||
title: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
background: active ? "#e3e5e7" : "#fff",
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: 4,
|
||||
fontWeight: bold ? 700 : 500,
|
||||
fontStyle: italic ? "italic" : "normal",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
minWidth: 28,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <span aria-hidden style={{ width: 1, background: "#c9cccf", margin: "2px 4px" }} />;
|
||||
}
|
||||
@@ -165,8 +165,8 @@ export default function InvoicesPage() {
|
||||
</s-stack>
|
||||
|
||||
{isLoading ? (
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-spinner size="small" accessibilityLabel="Loading orders" />
|
||||
<s-stack direction="inline" gap="base" alignItems="center">
|
||||
<s-spinner size="base" accessibilityLabel="Loading orders" />
|
||||
<s-text tone="neutral">Loading…</s-text>
|
||||
</s-stack>
|
||||
) : orders.length === 0 ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deleteStoredLogo,
|
||||
storeUploadedLogo,
|
||||
} from "../services/invoice/logoCache.server";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
|
||||
interface SettingsFieldErrors {
|
||||
vatId?: string;
|
||||
@@ -152,6 +153,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
smtpFromName: str("smtpFromName"),
|
||||
smtpFromEmail: str("smtpFromEmail"),
|
||||
smtpReplyTo: str("smtpReplyTo"),
|
||||
emailSubjectDe: str("emailSubjectDe"),
|
||||
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
||||
emailSubjectEn: str("emailSubjectEn"),
|
||||
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
||||
};
|
||||
|
||||
await db.shopSettings.upsert({
|
||||
@@ -361,6 +366,43 @@ export default function SettingsRoute() {
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Email templates">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
These templates are used when sending the invoice PDF by email.
|
||||
Leave a field empty to fall back to the built-in default.
|
||||
</s-paragraph>
|
||||
|
||||
<Field
|
||||
label="Subject (German)"
|
||||
name="emailSubjectDe"
|
||||
defaultValue={settings.emailSubjectDe}
|
||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
||||
/>
|
||||
<RichTextEditor
|
||||
name="emailBodyHtmlDe"
|
||||
label="Body (German)"
|
||||
defaultValue={settings.emailBodyHtmlDe}
|
||||
variables={EMAIL_VARS}
|
||||
minHeight={220}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Subject (English)"
|
||||
name="emailSubjectEn"
|
||||
defaultValue={settings.emailSubjectEn}
|
||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
||||
/>
|
||||
<RichTextEditor
|
||||
name="emailBodyHtmlEn"
|
||||
label="Body (English)"
|
||||
defaultValue={settings.emailBodyHtmlEn}
|
||||
variables={EMAIL_VARS}
|
||||
minHeight={220}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section>
|
||||
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
|
||||
{isSaving ? <s-text tone="neutral">Saving…</s-text> : null}
|
||||
@@ -374,6 +416,17 @@ export default function SettingsRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_VARS = [
|
||||
{ token: "{{invoiceNumber}}" },
|
||||
{ token: "{{customerName}}" },
|
||||
{ token: "{{customerFirstName}}" },
|
||||
{ token: "{{orderName}}" },
|
||||
{ token: "{{totalGross}}" },
|
||||
{ token: "{{dueDate}}" },
|
||||
{ token: "{{companyName}}" },
|
||||
{ token: "{{ownerName}}" },
|
||||
];
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
|
||||
@@ -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