From 04933fcac6839530093ab00fcfcbbd9720e48529 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 8 May 2026 23:06:40 +0200 Subject: [PATCH] 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 --- app/components/RichTextEditor.tsx | 253 +++++++ app/routes/app.invoices.tsx | 4 +- app/routes/app.settings.tsx | 53 ++ app/services/invoice/email.server.ts | 115 +++- package-lock.json | 641 +++++++++++++++++- package.json | 4 + .../migration.sql | 55 ++ prisma/schema.prisma | 6 + 8 files changed, 1120 insertions(+), 11 deletions(-) create mode 100644 app/components/RichTextEditor.tsx create mode 100644 prisma/migrations/20260508205959_add_email_templates/migration.sql diff --git a/app/components/RichTextEditor.tsx b/app/components/RichTextEditor.tsx new file mode 100644 index 0000000..6f203e1 --- /dev/null +++ b/app/components/RichTextEditor.tsx @@ -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
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 || "

", + 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 ( +
+ {label ? ( + + ) : null} +