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
+2 -2
View File
@@ -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 ? (
+53
View File
@@ -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;