many updates :-)

This commit is contained in:
Gerhard Scheikl
2026-05-08 10:40:19 +02:00
parent 5b2aa5d62b
commit 770c6fd16a
16 changed files with 876 additions and 151 deletions
+161 -35
View File
@@ -7,15 +7,25 @@ import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const [settings, recent] = await Promise.all([
const [settings, recent, counts] = await Promise.all([
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
db.invoice.findMany({
where: { shopDomain: session.shop },
orderBy: [{ issuedAt: "desc" }],
take: 10,
take: 8,
}),
db.invoice.groupBy({
by: ["status"],
where: { shopDomain: session.shop },
_count: { _all: true },
}),
]);
const total = counts.reduce((acc, row) => acc + row._count._all, 0);
const issuedCount = counts.find((c) => c.status === "issued")?._count._all ?? 0;
const sentCount = counts.find((c) => c.status === "sent")?._count._all ?? 0;
const cancelledCount = counts.find((c) => c.status === "cancelled")?._count._all ?? 0;
const settingsConfigured = !!(
settings &&
settings.companyName &&
@@ -25,12 +35,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
settingsConfigured,
metrics: { total, issuedCount, sentCount, cancelledCount },
recent: recent.map((i) => ({
id: i.id,
number: i.invoiceNumber,
kind: i.kind,
orderName: i.orderName,
version: i.version,
status: i.status,
sentAt: i.sentAt?.toISOString() ?? null,
cancelledAt: i.cancelledAt?.toISOString() ?? null,
issuedAt: i.issuedAt.toISOString(),
@@ -39,54 +51,168 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
};
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
function formatDate(iso: string | null): string {
if (!iso) return "—";
return dateFmt.format(new Date(iso));
}
interface RecentInvoice {
id: string;
number: string;
kind: string;
orderName: string;
version: number;
status: string;
sentAt: string | null;
cancelledAt: string | null;
issuedAt: string;
pdfUrl: string;
}
function statusBadge(invoice: RecentInvoice): {
tone: "success" | "info" | "critical" | "warning";
label: string;
} {
if (invoice.cancelledAt) return { tone: "critical", label: "Cancelled" };
if (invoice.kind === "storno") return { tone: "warning", label: "Storno" };
if (invoice.sentAt) return { tone: "success", label: "Sent" };
return { tone: "info", label: "Issued" };
}
export default function Index() {
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
const { settingsConfigured, metrics, recent } = useLoaderData<typeof loader>();
return (
<s-page heading="LinumIQ Invoice">
{!settingsConfigured && (
<s-banner tone="warning" heading="Configure your invoice settings">
Complete your company, bank and numbering details so generated
invoices are legally compliant.{" "}
<Link to="/app/settings">Open settings</Link>
<s-paragraph>
Complete your company, bank and numbering details so generated
invoices are legally compliant.
</s-paragraph>
<s-stack direction="inline" gap="base">
<s-link href="/app/settings">Open settings </s-link>
</s-stack>
</s-banner>
)}
<s-section heading="What this app does">
<s-paragraph>
Generates Austrian-compliant PDF invoices for your Shopify orders.
Trigger from the order page (Generate invoice action), via Shopify
Flow, or in bulk from the Invoices page. PDFs are stored on
Shopify Files and linked to each order via metafields.
</s-paragraph>
<s-section heading="Overview">
<s-grid gridTemplateColumns="repeat(4, minmax(0, 1fr))" gap="base">
<Metric label="Total invoices" value={metrics.total} />
<Metric label="Issued" value={metrics.issuedCount} tone="info" />
<Metric label="Sent" value={metrics.sentCount} tone="success" />
<Metric label="Cancelled" value={metrics.cancelledCount} tone="critical" />
</s-grid>
</s-section>
<s-section heading="Recent invoices">
<s-section
heading="Recent invoices"
padding="none"
accessibilityLabel="Recent invoices table"
>
{recent.length === 0 ? (
<s-paragraph>No invoices generated yet.</s-paragraph>
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No invoices yet</s-text>
<s-paragraph tone="neutral">
Generate your first invoice from the Invoices page or directly
from a Shopify order.
</s-paragraph>
<s-link href="/app/invoices">Open invoices </s-link>
</s-stack>
</s-box>
) : (
<s-unordered-list>
{recent.map((i) => (
<s-list-item key={i.id}>
{i.kind === "storno" ? "Storno " : ""}
{i.number} order {i.orderName} (v{i.version})
{i.cancelledAt
? " — cancelled"
: i.sentAt
? " — sent"
: ""}
{i.pdfUrl ? (
<>
{" "}
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
</>
) : null}
</s-list-item>
))}
</s-unordered-list>
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Invoice</s-table-header>
<s-table-header>Order</s-table-header>
<s-table-header>Issued</s-table-header>
<s-table-header listSlot="secondary">Status</s-table-header>
<s-table-header listSlot="labeled">PDF</s-table-header>
</s-table-header-row>
<s-table-body>
{recent.map((invoice) => {
const badge = statusBadge(invoice);
return (
<s-table-row key={invoice.id}>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-text type="strong">{invoice.number}</s-text>
{invoice.version > 1 ? (
<s-text tone="neutral">v{invoice.version}</s-text>
) : null}
</s-stack>
</s-table-cell>
<s-table-cell>{invoice.orderName}</s-table-cell>
<s-table-cell>{formatDate(invoice.issuedAt)}</s-table-cell>
<s-table-cell>
<s-badge tone={badge.tone}>{badge.label}</s-badge>
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end">
{invoice.pdfUrl ? (
<s-link href={invoice.pdfUrl} target="_blank">
Open
</s-link>
) : (
<s-text tone="neutral"></s-text>
)}
</s-stack>
</s-table-cell>
</s-table-row>
);
})}
</s-table-body>
</s-table>
)}
<Link to="/app/invoices">Open invoices page</Link>
<s-box padding="base">
<s-stack direction="inline" justifyContent="end">
<Link to="/app/invoices">View all invoices </Link>
</s-stack>
</s-box>
</s-section>
<s-section heading="How it works">
<s-stack direction="block" gap="base">
<s-paragraph>
LinumIQ Invoice generates Austrian-compliant PDF invoices for your
Shopify orders. PDFs are stored on Shopify Files and linked to
each order via metafields.
</s-paragraph>
<s-paragraph tone="neutral">
Trigger generation from the order page (Generate invoice action),
via Shopify Flow, or in bulk from the Invoices page.
</s-paragraph>
</s-stack>
</s-section>
</s-page>
);
}
function Metric({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: "info" | "success" | "critical";
}) {
const valueTone = tone ?? "neutral";
return (
<s-box padding="base" background="subdued" border="base" borderRadius="base">
<s-stack direction="block" gap="small">
<s-text tone="neutral">{label}</s-text>
<s-heading>
<s-text tone={valueTone}>{value}</s-text>
</s-heading>
</s-stack>
</s-box>
);
}
+196 -78
View File
@@ -6,12 +6,15 @@ import db from "../db.server";
interface RecentOrder {
id: string; // gid
numericId: string;
name: string;
createdAt: string;
totalPrice: string;
currency: string;
customerName: string;
hasInvoice: boolean;
invoiceNumber?: string;
invoiceVersion?: number;
invoiceSent?: boolean;
invoiceCancelled?: boolean;
pdfUrl?: string;
@@ -26,21 +29,26 @@ const RECENT_ORDERS_QUERY = `#graphql
createdAt
displayFinancialStatus
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName }
}
}
}
`;
type Filter = "all" | "missing" | "with";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const url = new URL(request.url);
const filter = url.searchParams.get("filter") ?? "all";
const filterParam = (url.searchParams.get("filter") ?? "all") as Filter;
const filter: Filter = ["all", "missing", "with"].includes(filterParam)
? filterParam
: "all";
// Recent orders from Shopify (first 25).
let orders: RecentOrder[] = [];
try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
const json = (await res.json()) as {
data?: {
orders?: {
@@ -49,6 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
name: string;
createdAt: string;
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
customer?: { firstName: string | null; lastName: string | null } | null;
}>;
};
};
@@ -56,7 +65,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const nodes = json.data?.orders?.nodes ?? [];
const orderIds = nodes.map((n) => n.id);
// Look up which of these orders already have a non-cancelled invoice.
const invoices = await db.invoice.findMany({
where: {
shopDomain: session.shop,
@@ -72,14 +80,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
orders = nodes.map((n) => {
const inv = latestByOrder.get(n.id);
const customer = n.customer
? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim()
: "";
return {
id: n.id,
numericId: n.id.replace(/^.*\//, ""),
name: n.name,
createdAt: n.createdAt,
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
customerName: customer || "Guest",
hasInvoice: !!inv && !inv.cancelledAt,
invoiceNumber: inv?.invoiceNumber,
invoiceVersion: inv?.version,
invoiceSent: !!inv?.sentAt,
invoiceCancelled: !!inv?.cancelledAt,
pdfUrl: inv?.pdfUrl,
@@ -89,103 +103,207 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
console.warn("Failed to load recent orders:", err);
}
const allCount = orders.length;
const withCount = orders.filter((o) => o.hasInvoice).length;
const missingCount = allCount - withCount;
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
return { orders, filter };
return {
orders,
filter,
counts: { all: allCount, with: withCount, missing: missingCount },
};
};
const dateFmt = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
const moneyFmt = new Intl.NumberFormat("de-AT", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatMoney(amount: string, currency: string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return `${amount} ${currency}`;
return `${moneyFmt.format(n)} ${currency}`;
}
export default function InvoicesPage() {
const { orders, filter } = useLoaderData<typeof loader>();
const { orders, filter, counts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
return (
<s-page heading="Invoices">
<s-section heading="Recent orders">
<s-paragraph>
Generate or view the latest invoice for each order. For sent
invoices, use Cancel & reissue to issue a Stornorechnung followed
by a fresh invoice.
</s-paragraph>
<s-section heading="Recent orders" padding="none">
<s-box padding="base">
<s-stack direction="block" gap="base">
<s-paragraph>
Generate the invoice for an order, regenerate an unsent draft,
or cancel-and-reissue a sent one. Newest orders appear first.
</s-paragraph>
<s-stack direction="inline" gap="base">
<Link to="?filter=all">All</Link>
<Link to="?filter=missing">Missing invoice</Link>
<Link to="?filter=with">Has invoice</Link>
</s-stack>
<s-stack direction="inline" gap="small" alignItems="center">
<FilterChip to="?filter=all" active={filter === "all"} count={counts.all}>
All
</FilterChip>
<FilterChip
to="?filter=missing"
active={filter === "missing"}
count={counts.missing}
>
Missing invoice
</FilterChip>
<FilterChip to="?filter=with" active={filter === "with"} count={counts.with}>
Has invoice
</FilterChip>
</s-stack>
</s-stack>
</s-box>
{isLoading ? (
<s-paragraph>Loading</s-paragraph>
<s-box padding="base">
<s-stack direction="inline" gap="small" alignItems="center">
<s-spinner size="small" accessibilityLabel="Loading orders" />
<s-text tone="neutral">Loading</s-text>
</s-stack>
</s-box>
) : orders.length === 0 ? (
<s-paragraph>No orders match the current filter.</s-paragraph>
<s-box padding="base">
<s-stack direction="block" gap="base" alignItems="center">
<s-text type="strong">No orders match this filter</s-text>
<s-paragraph tone="neutral">
Try a different filter or wait for new orders.
</s-paragraph>
</s-stack>
</s-box>
) : (
<s-unordered-list>
{orders.map((o) => (
<OrderRow key={o.id} order={o} />
))}
</s-unordered-list>
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Customer</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header format="numeric">Total</s-table-header>
<s-table-header listSlot="secondary">Invoice</s-table-header>
<s-table-header listSlot="labeled">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{orders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</s-table-body>
</s-table>
)}
</s-section>
<s-section heading="Bulk generate">
<s-paragraph>
Use the buttons next to each order to generate one at a time. (A
true multi-select bulk run will be added once the admin block
extension is installed.)
</s-paragraph>
<s-paragraph>
Tip: filter by <em>Missing invoice</em> to see orders that still
need one.
</s-paragraph>
<s-section heading="About this page">
<s-stack direction="block" gap="small">
<s-paragraph>
Buttons trigger the same generation pipeline used by the order
page action and Shopify Flow. PDFs are uploaded to Shopify Files
and linked back to the order via metafields.
</s-paragraph>
<s-paragraph tone="neutral">
Tip: filter by <em>Missing invoice</em> to find orders that still
need one.
</s-paragraph>
</s-stack>
</s-section>
</s-page>
);
}
function OrderRow({ order }: { order: RecentOrder }) {
const numericId = order.id.replace(/^.*\//, "");
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const isBusy = fetcher.state !== "idle";
function FilterChip({
to,
active,
count,
children,
}: {
to: string;
active: boolean;
count: number;
children: React.ReactNode;
}) {
return (
<s-list-item>
<s-stack direction="inline" gap="base" align-items="center">
<strong>{order.name}</strong>
<span>{order.totalPrice} {order.currency}</span>
{order.hasInvoice ? (
<span>
{order.invoiceNumber}
{order.invoiceCancelled
? " (cancelled)"
: order.invoiceSent
? " (sent)"
: ""}
{order.pdfUrl ? (
<>
{" — "}
<a href={order.pdfUrl} target="_blank" rel="noreferrer">PDF</a>
</>
) : null}
</span>
) : (
<span> no invoice yet</span>
)}
<fetcher.Form method="post" action={`/api/orders/${numericId}/invoice`}>
<s-button type="submit" disabled={isBusy} variant="primary">
{order.hasInvoice
? order.invoiceSent
? "Cancel & reissue"
: "Regenerate"
: "Generate"}
</s-button>
{order.hasInvoice && order.invoiceSent && (
<input type="hidden" name="action" value="cancel_reissue" />
)}
</fetcher.Form>
{fetcher.data?.error && (
<span style={{ color: "red" }}>{fetcher.data.error}</span>
)}
</s-stack>
</s-list-item>
<Link to={to} style={{ textDecoration: "none" }}>
<s-clickable-chip color={active ? "strong" : "base"} accessibilityLabel={String(children)}>
{children} ({count})
</s-clickable-chip>
</Link>
);
}
function OrderRow({ order }: { order: RecentOrder }) {
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const isBusy = fetcher.state !== "idle";
const isCancelReissue = order.hasInvoice && order.invoiceSent;
const buttonLabel = !order.hasInvoice
? "Generate"
: order.invoiceSent
? "Cancel & reissue"
: "Regenerate";
return (
<s-table-row>
<s-table-cell>
<s-stack direction="block" gap="none">
<s-text type="strong">{order.name}</s-text>
</s-stack>
</s-table-cell>
<s-table-cell>{order.customerName}</s-table-cell>
<s-table-cell>{dateFmt.format(new Date(order.createdAt))}</s-table-cell>
<s-table-cell>{formatMoney(order.totalPrice, order.currency)}</s-table-cell>
<s-table-cell>
{order.hasInvoice ? (
<s-stack direction="block" gap="none">
<s-stack direction="inline" gap="small" alignItems="center">
<s-text type="strong">{order.invoiceNumber}</s-text>
{order.invoiceCancelled ? (
<s-badge tone="critical">Cancelled</s-badge>
) : order.invoiceSent ? (
<s-badge tone="success">Sent</s-badge>
) : (
<s-badge tone="info">Issued</s-badge>
)}
{order.invoiceVersion && order.invoiceVersion > 1 ? (
<s-text tone="neutral">v{order.invoiceVersion}</s-text>
) : null}
</s-stack>
{fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text>
) : null}
</s-stack>
) : (
<s-text tone="neutral"></s-text>
)}
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small" justifyContent="end" alignItems="center">
{order.pdfUrl ? (
<s-link href={order.pdfUrl} target="_blank">
PDF
</s-link>
) : null}
<fetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
{isCancelReissue ? (
<input type="hidden" name="action" value="cancel_reissue" />
) : null}
<s-button
type="submit"
disabled={isBusy}
variant={order.hasInvoice ? "secondary" : "primary"}
tone={isCancelReissue ? "critical" : "auto"}
>
{isBusy ? "Working…" : buttonLabel}
</s-button>
</fetcher.Form>
</s-stack>
</s-table-cell>
</s-table-row>
);
}
+99 -19
View File
@@ -8,6 +8,11 @@ import {
isValidIban,
normaliseIban,
} from "../services/invoice/validation";
import {
STORED_LOGO_SENTINEL,
deleteStoredLogo,
storeUploadedLogo,
} from "../services/invoice/logoCache.server";
interface SettingsFieldErrors {
vatId?: string;
@@ -16,6 +21,7 @@ interface SettingsFieldErrors {
smtpPort?: string;
paymentTermDays?: string;
invoiceSeed?: string;
logo?: string;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -25,7 +31,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
update: {},
create: { shopDomain: session.shop },
});
return { settings };
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) => {
@@ -72,6 +85,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
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 };
}
@@ -102,8 +141,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
paymentTermDays: paymentTermDays ?? 14,
footerNote: str("footerNote"),
footerNoteEn: str("footerNoteEn"),
kleinunternehmer: bool("kleinunternehmer"),
logoUrl: str("logoUrl"),
logoUrl: resolvedLogoUrl,
smtpHost: str("smtpHost"),
smtpPort: smtpPort ?? 587,
smtpSecure: bool("smtpSecure"),
@@ -124,30 +164,38 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
export default function SettingsRoute() {
const { settings } = useLoaderData<typeof loader>();
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-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>
<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">Settings saved.</s-banner>
<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">
Please fix the errors highlighted below.
<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">
<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} />
@@ -252,8 +300,37 @@ export default function SettingsRoute() {
{ value: "en", label: "English (en)" },
]}
/>
<Field label="Footer note (optional)" name="footerNote" defaultValue={settings.footerNote} />
<Field label="Logo URL (PNG/JPG, served from Shopify Files or any HTTPS URL)" name="logoUrl" defaultValue={settings.logoUrl} />
<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>
@@ -284,11 +361,14 @@ export default function SettingsRoute() {
</s-stack>
</s-section>
<s-stack direction="inline" gap="base">
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
</s-stack>
<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>
);