many updates :-)
This commit is contained in:
+99
-19
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user