feat(email): per-variant paid/unpaid body templates in settings
The previous single body field could not distinguish 'use the default' from 'save the pre-filled default', so the first settings save locked the hint in as a custom override for ALL orders — the paid/unpaid default logic never ran. Give each payment state its own editable body field (emailBodyHtmlDePaid / emailBodyHtmlEnPaid) with its own empty-fallback. A body submitted unchanged from its built-in default is stored as empty, so 'use default' survives saves and future default updates propagate.
This commit is contained in:
Vendored
+3
-1
@@ -2,6 +2,8 @@
|
|||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"setopt": true,
|
"setopt": true,
|
||||||
"npx shopify": true,
|
"npx shopify": true,
|
||||||
"npx tsx": true
|
"npx tsx": true,
|
||||||
|
"./update_dev_system.sh": true,
|
||||||
|
"./show_dev_logs.sh": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+54
-16
@@ -17,8 +17,10 @@ import {
|
|||||||
import { encryptField } from "../services/crypto/fieldCrypto.server";
|
import { encryptField } from "../services/crypto/fieldCrypto.server";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
import { RichTextEditor } from "../components/RichTextEditor";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EMAIL_BODY_DE,
|
DEFAULT_EMAIL_BODY_DE_PAID,
|
||||||
DEFAULT_EMAIL_BODY_EN,
|
DEFAULT_EMAIL_BODY_DE_UNPAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_PAID,
|
||||||
|
DEFAULT_EMAIL_BODY_EN_UNPAID,
|
||||||
DEFAULT_EMAIL_SUBJECT_DE,
|
DEFAULT_EMAIL_SUBJECT_DE,
|
||||||
DEFAULT_EMAIL_SUBJECT_EN,
|
DEFAULT_EMAIL_SUBJECT_EN,
|
||||||
} from "../services/invoice/emailTemplates";
|
} from "../services/invoice/emailTemplates";
|
||||||
@@ -80,6 +82,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Email bodies are pre-filled with the built-in default in the editor. If the
|
||||||
|
// submitted value still equals that default, store an empty string instead so
|
||||||
|
// "empty = use built-in default" survives a save (and future default updates
|
||||||
|
// propagate). Only a genuinely edited body is persisted as a custom override.
|
||||||
|
const tpl = (k: string, builtIn: string) => {
|
||||||
|
const v = str(k);
|
||||||
|
return normaliseHtml(v) === normaliseHtml(builtIn) ? "" : v;
|
||||||
|
};
|
||||||
|
|
||||||
const vatId = str("vatId").toUpperCase();
|
const vatId = str("vatId").toUpperCase();
|
||||||
if (vatId && !isValidAtVatId(vatId)) {
|
if (vatId && !isValidAtVatId(vatId)) {
|
||||||
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
|
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
|
||||||
@@ -214,9 +225,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
smtpFromEmail: str("smtpFromEmail"),
|
smtpFromEmail: str("smtpFromEmail"),
|
||||||
smtpReplyTo: str("smtpReplyTo"),
|
smtpReplyTo: str("smtpReplyTo"),
|
||||||
emailSubjectDe: str("emailSubjectDe"),
|
emailSubjectDe: str("emailSubjectDe"),
|
||||||
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
emailBodyHtmlDe: tpl("emailBodyHtmlDe", DEFAULT_EMAIL_BODY_DE_UNPAID),
|
||||||
emailSubjectEn: str("emailSubjectEn"),
|
emailSubjectEn: str("emailSubjectEn"),
|
||||||
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
emailBodyHtmlEn: tpl("emailBodyHtmlEn", DEFAULT_EMAIL_BODY_EN_UNPAID),
|
||||||
|
emailBodyHtmlDePaid: tpl("emailBodyHtmlDePaid", DEFAULT_EMAIL_BODY_DE_PAID),
|
||||||
|
emailBodyHtmlEnPaid: tpl("emailBodyHtmlEnPaid", DEFAULT_EMAIL_BODY_EN_PAID),
|
||||||
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
|
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
|
||||||
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
|
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
|
||||||
};
|
};
|
||||||
@@ -421,43 +434,59 @@ export default function SettingsRoute() {
|
|||||||
<s-stack direction="block" gap="base">
|
<s-stack direction="block" gap="base">
|
||||||
<s-paragraph>
|
<s-paragraph>
|
||||||
These templates are used when sending the invoice PDF by email.
|
These templates are used when sending the invoice PDF by email.
|
||||||
Leave a field empty to fall back to the built-in default. The
|
There are two body variants per language: the <strong>unpaid</strong>
|
||||||
built-in default has two variants per language: orders that are
|
{" "}body is sent for outstanding orders and includes the
|
||||||
already paid are sent a body <em>without</em> the bank-transfer
|
bank-transfer reference hint; the <strong>paid</strong> body is sent
|
||||||
reference hint, while outstanding (unpaid) orders include it.
|
for orders that are already settled (paid / refunded / voided) and
|
||||||
A custom template you set here is used for both paid and unpaid
|
omits that hint. Leave any field empty to fall back to its built-in
|
||||||
orders — the editor below shows the unpaid default.
|
default.
|
||||||
</s-paragraph>
|
</s-paragraph>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Subject (German)"
|
label="Subject (German)"
|
||||||
name="emailSubjectDe"
|
name="emailSubjectDe"
|
||||||
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
|
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
|
||||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
helpText="Variables like {{invoiceNumber}} are substituted at send time. Used for both paid and unpaid."
|
||||||
/>
|
/>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
name="emailBodyHtmlDe"
|
name="emailBodyHtmlDe"
|
||||||
label="Body (German)"
|
label="Body (German, unpaid)"
|
||||||
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE_UNPAID}
|
||||||
variables={EMAIL_VARS}
|
variables={EMAIL_VARS}
|
||||||
minHeight={220}
|
minHeight={220}
|
||||||
logoDataUrl={logoPreviewDataUrl}
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
/>
|
/>
|
||||||
|
<RichTextEditor
|
||||||
|
name="emailBodyHtmlDePaid"
|
||||||
|
label="Body (German, paid)"
|
||||||
|
defaultValue={settings.emailBodyHtmlDePaid || DEFAULT_EMAIL_BODY_DE_PAID}
|
||||||
|
variables={EMAIL_VARS}
|
||||||
|
minHeight={200}
|
||||||
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Subject (English)"
|
label="Subject (English)"
|
||||||
name="emailSubjectEn"
|
name="emailSubjectEn"
|
||||||
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
|
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
|
||||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
helpText="Variables like {{invoiceNumber}} are substituted at send time. Used for both paid and unpaid."
|
||||||
/>
|
/>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
name="emailBodyHtmlEn"
|
name="emailBodyHtmlEn"
|
||||||
label="Body (English)"
|
label="Body (English, unpaid)"
|
||||||
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN_UNPAID}
|
||||||
variables={EMAIL_VARS}
|
variables={EMAIL_VARS}
|
||||||
minHeight={220}
|
minHeight={220}
|
||||||
logoDataUrl={logoPreviewDataUrl}
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
/>
|
/>
|
||||||
|
<RichTextEditor
|
||||||
|
name="emailBodyHtmlEnPaid"
|
||||||
|
label="Body (English, paid)"
|
||||||
|
defaultValue={settings.emailBodyHtmlEnPaid || DEFAULT_EMAIL_BODY_EN_PAID}
|
||||||
|
variables={EMAIL_VARS}
|
||||||
|
minHeight={200}
|
||||||
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
|
/>
|
||||||
</s-stack>
|
</s-stack>
|
||||||
</s-section>
|
</s-section>
|
||||||
|
|
||||||
@@ -524,6 +553,15 @@ const EMAIL_VARS = [
|
|||||||
{ token: "{{shopWebsite}}" },
|
{ token: "{{shopWebsite}}" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses insignificant differences (whitespace, attribute order is left
|
||||||
|
* as-is) so a rich-text-editor round-trip of an unchanged default still
|
||||||
|
* compares equal to the built-in template.
|
||||||
|
*/
|
||||||
|
function normaliseHtml(html: string): string {
|
||||||
|
return html.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -91,26 +91,23 @@ export async function sendInvoiceEmail(
|
|||||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||||
const subject = renderTemplate(customSubject, vars, { html: false });
|
const subject = renderTemplate(customSubject, vars, { html: false });
|
||||||
|
|
||||||
// Pick the default body variant for the invoice's payment state: orders that
|
// Pick the body variant for the invoice's payment state: orders that are
|
||||||
// are already settled (paid / refunded / voided) get a body WITHOUT the
|
// already settled (paid / refunded / voided) get a body WITHOUT the
|
||||||
// bank-transfer reference hint; outstanding orders (unpaid / partial) keep
|
// bank-transfer reference hint; outstanding orders (unpaid / partial) keep
|
||||||
// it. Legacy rows have an empty paymentStatus → treated as unpaid.
|
// it. Legacy invoice rows have an empty paymentStatus → treated as unpaid.
|
||||||
// A merchant-supplied custom template always wins over the defaults.
|
// Each variant has its own merchant-customisable field; empty falls back to
|
||||||
|
// the built-in default for that variant.
|
||||||
const isPaid =
|
const isPaid =
|
||||||
invoice.paymentStatus === "paid" ||
|
invoice.paymentStatus === "paid" ||
|
||||||
invoice.paymentStatus === "refunded" ||
|
invoice.paymentStatus === "refunded" ||
|
||||||
invoice.paymentStatus === "voided";
|
invoice.paymentStatus === "voided";
|
||||||
const defaultBody =
|
const customBodyHtml = isPaid
|
||||||
language === "en"
|
? language === "en"
|
||||||
? isPaid
|
? settings.emailBodyHtmlEnPaid || DEFAULT_EMAIL_BODY_EN_PAID
|
||||||
? DEFAULT_EMAIL_BODY_EN_PAID
|
: settings.emailBodyHtmlDePaid || DEFAULT_EMAIL_BODY_DE_PAID
|
||||||
: DEFAULT_EMAIL_BODY_EN_UNPAID
|
: language === "en"
|
||||||
: isPaid
|
? settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN_UNPAID
|
||||||
? DEFAULT_EMAIL_BODY_DE_PAID
|
: settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE_UNPAID;
|
||||||
: DEFAULT_EMAIL_BODY_DE_UNPAID;
|
|
||||||
const customBodyHtml =
|
|
||||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
|
||||||
defaultBody;
|
|
||||||
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
|
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
|
||||||
|
|
||||||
// If the rendered body references the inline logo, attach it.
|
// If the rendered body references the inline logo, attach it.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ShopSettings" ADD COLUMN "emailBodyHtmlDePaid" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "ShopSettings" ADD COLUMN "emailBodyHtmlEnPaid" TEXT NOT NULL DEFAULT '';
|
||||||
@@ -94,10 +94,14 @@ model ShopSettings {
|
|||||||
smtpReplyTo String @default("")
|
smtpReplyTo String @default("")
|
||||||
|
|
||||||
// Email templates (HTML, with {{var}} placeholders). Empty = use defaults.
|
// Email templates (HTML, with {{var}} placeholders). Empty = use defaults.
|
||||||
|
// The Body fields are the UNPAID (outstanding) variant; the *Paid fields are
|
||||||
|
// sent for orders that are already settled (paid/refunded/voided).
|
||||||
emailSubjectDe String @default("")
|
emailSubjectDe String @default("")
|
||||||
emailBodyHtmlDe String @default("")
|
emailBodyHtmlDe String @default("")
|
||||||
emailSubjectEn String @default("")
|
emailSubjectEn String @default("")
|
||||||
emailBodyHtmlEn String @default("")
|
emailBodyHtmlEn String @default("")
|
||||||
|
emailBodyHtmlDePaid String @default("")
|
||||||
|
emailBodyHtmlEnPaid String @default("")
|
||||||
|
|
||||||
// Automations (webhook-driven, as a fallback to Shopify Flow which only
|
// Automations (webhook-driven, as a fallback to Shopify Flow which only
|
||||||
// exposes custom-app actions on Plus stores).
|
// exposes custom-app actions on Plus stores).
|
||||||
|
|||||||
Reference in New Issue
Block a user