573dfbfd50
Mirrors the layout from data/mail_template.png:
- Company name + greeting headline
- Body referencing the invoice number
- Inline logo (cid:invoice-logo) attached automatically
- Footer with mailto + website links
New template vars: {{shopEmail}}, {{shopWebsite}}.
Settings UI prefills empty fields with the defaults so users see and
can tweak them without losing the fallback.
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
|
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
|
import { authenticate } from "../shopify.server";
|
|
import db from "../db.server";
|
|
import {
|
|
isValidAtVatId,
|
|
isValidBic,
|
|
isValidIban,
|
|
normaliseIban,
|
|
} from "../services/invoice/validation";
|
|
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
|
|
import {
|
|
deleteStoredLogo,
|
|
storeUploadedLogo,
|
|
} from "../services/invoice/logoCache.server";
|
|
import { RichTextEditor } from "../components/RichTextEditor";
|
|
import {
|
|
DEFAULT_EMAIL_BODY_DE,
|
|
DEFAULT_EMAIL_BODY_EN,
|
|
DEFAULT_EMAIL_SUBJECT_DE,
|
|
DEFAULT_EMAIL_SUBJECT_EN,
|
|
} from "../services/invoice/emailTemplates";
|
|
|
|
interface SettingsFieldErrors {
|
|
vatId?: string;
|
|
iban?: string;
|
|
bic?: string;
|
|
smtpPort?: string;
|
|
paymentTermDays?: string;
|
|
invoiceSeed?: string;
|
|
logo?: string;
|
|
}
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const settings = await db.shopSettings.upsert({
|
|
where: { shopDomain: session.shop },
|
|
update: {},
|
|
create: { shopDomain: session.shop },
|
|
});
|
|
let logoPreviewDataUrl: string | null = null;
|
|
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
|
|
const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } });
|
|
if (cached) {
|
|
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
|
}
|
|
}
|
|
return { settings, logoPreviewDataUrl };
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const form = await request.formData();
|
|
const errors: SettingsFieldErrors = {};
|
|
|
|
const str = (k: string, fallback = "") => (form.get(k) ?? fallback).toString().trim();
|
|
const bool = (k: string) => form.get(k) === "on";
|
|
const intOrNull = (k: string): number | null => {
|
|
const raw = form.get(k);
|
|
if (raw === null || raw === "") return null;
|
|
const n = parseInt(raw.toString(), 10);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
|
|
const vatId = str("vatId").toUpperCase();
|
|
if (vatId && !isValidAtVatId(vatId)) {
|
|
errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678).";
|
|
}
|
|
|
|
const iban = normaliseIban(str("iban"));
|
|
if (iban && !isValidIban(iban)) {
|
|
errors.iban = "Invalid IBAN (failed checksum or unknown country length).";
|
|
}
|
|
|
|
const bic = str("bic").toUpperCase();
|
|
if (bic && !isValidBic(bic)) {
|
|
errors.bic = "Invalid BIC (8 or 11 alphanumeric characters).";
|
|
}
|
|
|
|
const smtpPort = intOrNull("smtpPort");
|
|
if (smtpPort !== null && (smtpPort < 1 || smtpPort > 65535)) {
|
|
errors.smtpPort = "Port must be between 1 and 65535.";
|
|
}
|
|
|
|
const paymentTermDays = intOrNull("paymentTermDays");
|
|
if (paymentTermDays !== null && (paymentTermDays < 0 || paymentTermDays > 365)) {
|
|
errors.paymentTermDays = "Must be between 0 and 365.";
|
|
}
|
|
|
|
const invoiceSeed = intOrNull("invoiceSeed");
|
|
if (invoiceSeed !== null && invoiceSeed < 0) {
|
|
errors.invoiceSeed = "Must be a non-negative number.";
|
|
}
|
|
|
|
// --- Logo handling --------------------------------------------------------
|
|
// The settings page allows three actions on the logo:
|
|
// 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`.
|
|
// 2. Remove the current logo (`removeLogo=on`).
|
|
// 3. Provide an external URL via the `logoUrl` field.
|
|
// If a file is uploaded it wins over a manually-entered URL.
|
|
let resolvedLogoUrl = str("logoUrl");
|
|
const removeLogo = bool("removeLogo");
|
|
const logoFile = form.get("logoFile");
|
|
const hasUpload =
|
|
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
|
|
|
|
if (removeLogo && !hasUpload) {
|
|
await deleteStoredLogo(session.shop);
|
|
resolvedLogoUrl = "";
|
|
} else if (hasUpload) {
|
|
const file = logoFile as File;
|
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
const stored = await storeUploadedLogo(session.shop, buf, file.type);
|
|
if (!stored.ok) {
|
|
errors.logo = stored.error ?? "Failed to store uploaded logo.";
|
|
} else {
|
|
resolvedLogoUrl = STORED_LOGO_SENTINEL;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
return { ok: false, errors, savedAt: null as string | null };
|
|
}
|
|
|
|
const data = {
|
|
companyName: str("companyName"),
|
|
legalForm: str("legalForm"),
|
|
ownerName: str("ownerName"),
|
|
addressLine1: str("addressLine1"),
|
|
addressLine2: str("addressLine2"),
|
|
postalCode: str("postalCode"),
|
|
city: str("city"),
|
|
countryCode: str("countryCode", "AT").toUpperCase(),
|
|
phone: str("phone"),
|
|
email: str("email"),
|
|
website: str("website"),
|
|
vatId,
|
|
taxNumber: str("taxNumber"),
|
|
registrationNo: str("registrationNo"),
|
|
registrationCourt: str("registrationCourt"),
|
|
bankName: str("bankName"),
|
|
iban,
|
|
bic,
|
|
giroCodeEnabled: bool("giroCodeEnabled"),
|
|
numberingMode: str("numberingMode", "shopify_order_number"),
|
|
invoicePrefix: str("invoicePrefix"),
|
|
invoiceSeed: invoiceSeed ?? 1000,
|
|
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
|
|
paymentTermDays: paymentTermDays ?? 14,
|
|
footerNote: str("footerNote"),
|
|
footerNoteEn: str("footerNoteEn"),
|
|
kleinunternehmer: bool("kleinunternehmer"),
|
|
logoUrl: resolvedLogoUrl,
|
|
smtpHost: str("smtpHost"),
|
|
smtpPort: smtpPort ?? 587,
|
|
smtpSecure: bool("smtpSecure"),
|
|
smtpUser: str("smtpUser"),
|
|
smtpPassword: str("smtpPassword"),
|
|
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({
|
|
where: { shopDomain: session.shop },
|
|
update: data,
|
|
create: { shopDomain: session.shop, ...data },
|
|
});
|
|
|
|
return { ok: true, errors: {}, savedAt: new Date().toISOString() };
|
|
};
|
|
|
|
export default function SettingsRoute() {
|
|
const { settings, logoPreviewDataUrl } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
const nav = useNavigation();
|
|
const isSaving = nav.state === "submitting";
|
|
const errors = actionData?.errors ?? {};
|
|
const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL;
|
|
// Show the URL field only when not using a stored upload — keeps the UI
|
|
// simpler and avoids the sentinel value leaking into the input.
|
|
const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl;
|
|
|
|
return (
|
|
<s-page heading="Invoice settings">
|
|
<s-section>
|
|
<s-paragraph>
|
|
Configure issuer details, bank, numbering, language and SMTP. These
|
|
values are written into every PDF invoice this app generates and must
|
|
match your legal records.
|
|
</s-paragraph>
|
|
</s-section>
|
|
|
|
{actionData?.ok && (
|
|
<s-banner tone="success" heading="Settings saved">
|
|
Your changes are now live and will be used for the next invoice.
|
|
</s-banner>
|
|
)}
|
|
{actionData && !actionData.ok && (
|
|
<s-banner tone="critical" heading="Please fix the highlighted errors">
|
|
Some fields below need attention before settings can be saved.
|
|
</s-banner>
|
|
)}
|
|
|
|
<Form method="post" encType="multipart/form-data">
|
|
<s-section heading="Company">
|
|
<s-stack direction="block" gap="base">
|
|
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
|
|
<Field label="Legal form (e.g. e.U., GmbH)" name="legalForm" defaultValue={settings.legalForm} />
|
|
<Field label="Owner / managing director" name="ownerName" defaultValue={settings.ownerName} />
|
|
<Field label="Address line 1" name="addressLine1" defaultValue={settings.addressLine1} />
|
|
<Field label="Address line 2" name="addressLine2" defaultValue={settings.addressLine2} />
|
|
<s-stack direction="inline" gap="base">
|
|
<Field label="Postal code" name="postalCode" defaultValue={settings.postalCode} />
|
|
<Field label="City" name="city" defaultValue={settings.city} />
|
|
<Field label="Country (ISO)" name="countryCode" defaultValue={settings.countryCode} />
|
|
</s-stack>
|
|
<s-stack direction="inline" gap="base">
|
|
<Field label="Phone" name="phone" defaultValue={settings.phone} />
|
|
<Field label="E-mail" name="email" defaultValue={settings.email} />
|
|
<Field label="Website" name="website" defaultValue={settings.website} />
|
|
</s-stack>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Legal identifiers">
|
|
<s-stack direction="block" gap="base">
|
|
<Field
|
|
label="VAT ID (UID)"
|
|
name="vatId"
|
|
defaultValue={settings.vatId}
|
|
error={errors.vatId}
|
|
helpText="Austrian format: ATU followed by 8 digits."
|
|
/>
|
|
<Field label="Tax number (Steuernummer)" name="taxNumber" defaultValue={settings.taxNumber} />
|
|
<Field label="Commercial register no. (FN)" name="registrationNo" defaultValue={settings.registrationNo} />
|
|
<Field label="Commercial register court" name="registrationCourt" defaultValue={settings.registrationCourt} />
|
|
<Toggle
|
|
label="I am a Kleinunternehmer (no VAT charged, § 6 Abs. 1 Z 27 UStG)"
|
|
name="kleinunternehmer"
|
|
checked={settings.kleinunternehmer}
|
|
/>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Bank">
|
|
<s-stack direction="block" gap="base">
|
|
<Field label="Bank name" name="bankName" defaultValue={settings.bankName} />
|
|
<Field
|
|
label="IBAN"
|
|
name="iban"
|
|
defaultValue={settings.iban}
|
|
error={errors.iban}
|
|
/>
|
|
<Field
|
|
label="BIC"
|
|
name="bic"
|
|
defaultValue={settings.bic}
|
|
error={errors.bic}
|
|
/>
|
|
<Toggle
|
|
label="Render GiroCode (EPC QR) on unpaid invoices"
|
|
name="giroCodeEnabled"
|
|
checked={settings.giroCodeEnabled}
|
|
/>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Invoice numbering">
|
|
<s-stack direction="block" gap="base">
|
|
<s-paragraph>
|
|
<strong>shopify_order_number</strong> reuses the Shopify order
|
|
number (e.g. <code>RE-1004</code>). <strong>prefix_sequential</strong>
|
|
{" "}allocates a strictly gapless number from an internal counter
|
|
(legally safest under § 11 UStG).
|
|
</s-paragraph>
|
|
<Select
|
|
label="Numbering mode"
|
|
name="numberingMode"
|
|
defaultValue={settings.numberingMode}
|
|
options={[
|
|
{ value: "shopify_order_number", label: "Use Shopify order number" },
|
|
{ value: "prefix_sequential", label: "Sequential (gapless, app-managed)" },
|
|
]}
|
|
/>
|
|
<Field label="Invoice number prefix" name="invoicePrefix" defaultValue={settings.invoicePrefix} />
|
|
<Field
|
|
label="Sequential start (last issued; next = this + 1)"
|
|
name="invoiceSeed"
|
|
type="number"
|
|
defaultValue={String(settings.invoiceSeed)}
|
|
error={errors.invoiceSeed}
|
|
/>
|
|
<Field
|
|
label="Payment term (days)"
|
|
name="paymentTermDays"
|
|
type="number"
|
|
defaultValue={String(settings.paymentTermDays)}
|
|
error={errors.paymentTermDays}
|
|
/>
|
|
<Select
|
|
label="Default language"
|
|
name="defaultLanguage"
|
|
defaultValue={settings.defaultLanguage}
|
|
options={[
|
|
{ value: "de", label: "German (de)" },
|
|
{ value: "en", label: "English (en)" },
|
|
]}
|
|
/>
|
|
<Field label="Footer note (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
|
|
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Logo">
|
|
<s-stack direction="block" gap="base">
|
|
<s-paragraph>
|
|
The logo is rendered in the top-right corner of every invoice
|
|
PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or
|
|
provide a publicly reachable HTTPS URL — uploads take precedence.
|
|
</s-paragraph>
|
|
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
|
|
{logoPreviewDataUrl && (
|
|
<s-stack direction="block" gap="small">
|
|
<s-text>Current logo:</s-text>
|
|
<img
|
|
src={logoPreviewDataUrl}
|
|
alt="Current invoice logo"
|
|
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
|
|
/>
|
|
<s-checkbox name="removeLogo" label="Remove current logo on save" />
|
|
</s-stack>
|
|
)}
|
|
<s-text>Upload a new logo:</s-text>
|
|
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
|
|
<Field
|
|
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
|
|
name="logoUrl"
|
|
defaultValue={visibleLogoUrl}
|
|
/>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="SMTP (used by the email Flow action)">
|
|
<s-stack direction="block" gap="base">
|
|
<s-stack direction="inline" gap="base">
|
|
<Field label="Host" name="smtpHost" defaultValue={settings.smtpHost} />
|
|
<Field
|
|
label="Port"
|
|
name="smtpPort"
|
|
type="number"
|
|
defaultValue={String(settings.smtpPort)}
|
|
error={errors.smtpPort}
|
|
/>
|
|
</s-stack>
|
|
<Toggle
|
|
label="Use TLS (SMTPS) — typically required for port 465"
|
|
name="smtpSecure"
|
|
checked={settings.smtpSecure}
|
|
/>
|
|
<Field label="Username" name="smtpUser" defaultValue={settings.smtpUser} />
|
|
<Field label="Password" name="smtpPassword" type="password" defaultValue={settings.smtpPassword} />
|
|
<s-stack direction="inline" gap="base">
|
|
<Field label="From name" name="smtpFromName" defaultValue={settings.smtpFromName} />
|
|
<Field label="From e-mail" name="smtpFromEmail" defaultValue={settings.smtpFromEmail} />
|
|
<Field label="Reply-to" name="smtpReplyTo" defaultValue={settings.smtpReplyTo} />
|
|
</s-stack>
|
|
</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 || DEFAULT_EMAIL_SUBJECT_DE}
|
|
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
|
/>
|
|
<RichTextEditor
|
|
name="emailBodyHtmlDe"
|
|
label="Body (German)"
|
|
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
|
variables={EMAIL_VARS}
|
|
minHeight={220}
|
|
/>
|
|
|
|
<Field
|
|
label="Subject (English)"
|
|
name="emailSubjectEn"
|
|
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
|
|
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
|
/>
|
|
<RichTextEditor
|
|
name="emailBodyHtmlEn"
|
|
label="Body (English)"
|
|
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
|
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}
|
|
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
|
Save settings
|
|
</s-button>
|
|
</s-stack>
|
|
</s-section>
|
|
</Form>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
const EMAIL_VARS = [
|
|
{ token: "{{invoiceNumber}}" },
|
|
{ token: "{{customerName}}" },
|
|
{ token: "{{customerFirstName}}" },
|
|
{ token: "{{orderName}}" },
|
|
{ token: "{{totalGross}}" },
|
|
{ token: "{{dueDate}}" },
|
|
{ token: "{{companyName}}" },
|
|
{ token: "{{ownerName}}" },
|
|
{ token: "{{shopEmail}}" },
|
|
{ token: "{{shopWebsite}}" },
|
|
];
|
|
|
|
interface FieldProps {
|
|
label: string;
|
|
name: string;
|
|
defaultValue?: string;
|
|
type?: string;
|
|
error?: string;
|
|
helpText?: string;
|
|
}
|
|
|
|
function Field({ label, name, defaultValue = "", type = "text", error, helpText }: FieldProps) {
|
|
// Polaris web-components don't expose a generic html `type` prop; we use
|
|
// distinct tags only when needed (e.g. password). The form action parses
|
|
// numeric strings server-side.
|
|
if (type === "password") {
|
|
return (
|
|
// s-password-field renders an obscured input
|
|
<s-password-field
|
|
label={label}
|
|
name={name}
|
|
value={defaultValue}
|
|
{...(error ? { error } : {})}
|
|
{...(helpText ? { details: helpText } : {})}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<s-text-field
|
|
label={label}
|
|
name={name}
|
|
value={defaultValue}
|
|
{...(error ? { error } : {})}
|
|
{...(helpText ? { details: helpText } : {})}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface ToggleProps { label: string; name: string; checked: boolean }
|
|
function Toggle({ label, name, checked }: ToggleProps) {
|
|
return (
|
|
<s-checkbox
|
|
name={name}
|
|
label={label}
|
|
{...(checked ? { checked: true } : {})}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SelectProps {
|
|
label: string;
|
|
name: string;
|
|
defaultValue: string;
|
|
options: { value: string; label: string }[];
|
|
}
|
|
function Select({ label, name, defaultValue, options }: SelectProps) {
|
|
return (
|
|
<s-select label={label} name={name} value={defaultValue}>
|
|
{options.map((o) => (
|
|
<s-option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</s-option>
|
|
))}
|
|
</s-select>
|
|
);
|
|
}
|