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")}`; } } else if (settings.logoUrl) { // External HTTPS URL — fine to display directly in the editor. logoPreviewDataUrl = settings.logoUrl; } 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(); const actionData = useActionData(); 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 ( 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.
shopify_order_number reuses the Shopify order number (e.g. RE-1004). prefix_sequential {" "}allocates a strictly gapless number from an internal counter (legally safest under § 11 UStG). 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. {errors.logo && {errors.logo}} {logoPreviewDataUrl && ( Current logo: Current invoice logo )} Upload a new logo: These templates are used when sending the invoice PDF by email. Leave a field empty to fall back to the built-in default. {actionData?.ok && ( Your changes are now live and will be used for the next invoice. )} {actionData && !actionData.ok && ( Some fields below need attention before settings can be saved. )} {isSaving ? Saving… : null} Save settings
); } 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 ); } return ( ); } interface ToggleProps { label: string; name: string; checked: boolean } function Toggle({ label, name, checked }: ToggleProps) { return ( ); } interface SelectProps { label: string; name: string; defaultValue: string; options: { value: string; label: string }[]; } function Select({ label, name, defaultValue, options }: SelectProps) { return ( {options.map((o) => ( {o.label} ))} ); }