From 8d9db174eca349618acd952e98e05cb666912bde Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Tue, 20 Oct 2026 01:38:17 +0100 Subject: [PATCH 1/2] Fix #899: Add font selection to invoice branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branding already supported logo upload, accent color, and tagline with persistence and PDF export, but font selection — an explicit acceptance requirement — was missing. Adds a fontFamily field to brand settings (Helvetica/Times-Roman/Courier, react-pdf's built-in fonts), a selector in the branding form with live preview, and wiring into both PDF export paths (invoice export and receipt). --- src/app/api/settings/branding/route.ts | 1 + src/components/InvoiceExportButton.tsx | 13 ++++++-- src/components/settings/BrandingForm.tsx | 39 ++++++++++++++++++++++-- src/lib/brandSettings.ts | 32 +++++++++++++++++++ src/lib/brandSettingsStore.ts | 8 ++++- src/lib/branding.ts | 8 ++++- 6 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/app/api/settings/branding/route.ts b/src/app/api/settings/branding/route.ts index b67d04e2..932ce8e3 100644 --- a/src/app/api/settings/branding/route.ts +++ b/src/app/api/settings/branding/route.ts @@ -60,6 +60,7 @@ export async function PUT(request: NextRequest) { logoUrl: parsed.data.logoUrl ?? null, accentColor: parsed.data.accentColor ?? null, tagline: parsed.data.tagline ?? null, + fontFamily: parsed.data.fontFamily ?? null, }); return NextResponse.json(saved, { status: 200 }); diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index bf0069a6..5f407885 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -3,7 +3,12 @@ import { useCallback, useState } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import { formatAmount } from '@stellar-split/sdk'; -import { DEFAULT_ACCENT_COLOR, type BrandSettings } from '@/lib/brandSettings'; +import { DEFAULT_ACCENT_COLOR, BRAND_FONT_OPTIONS, type BrandSettings } from '@/lib/brandSettings'; + +/** Resolves the react-pdf font name for a brand font (falls back to Helvetica). */ +function resolvePdfFontFamily(font: BrandSettings['fontFamily'] | undefined): string { + return BRAND_FONT_OPTIONS.find((o) => o.value === font)?.value ?? 'Helvetica'; +} interface Props { invoice: Invoice; @@ -39,6 +44,7 @@ async function downloadReceiptPdf(invoice: Invoice, total: bigint, brand: BrandS const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer'); const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR; + const fontFamily = resolvePdfFontFamily(brand?.fontFamily); const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null; const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id); const exportedAt = new Date().toLocaleString(); @@ -48,7 +54,7 @@ async function downloadReceiptPdf(invoice: Invoice, total: bigint, brand: BrandS : `https://stellarsplit-dapp.vercel.app/verify/${invoice.id}`; const styles = StyleSheet.create({ - page: { padding: 36, fontSize: 10, fontFamily: 'Helvetica', color: '#111', backgroundColor: '#fff' }, + page: { padding: 36, fontSize: 10, fontFamily, color: '#111', backgroundColor: '#fff' }, banner: { backgroundColor: accent, padding: '10 16', marginBottom: 18, borderRadius: 4 }, bannerTitle: { fontSize: 16, fontWeight: 'bold', color: '#fff' }, bannerSubtitle: { fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 }, @@ -172,12 +178,13 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) const brand = await resolveBranding(); const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR; + const fontFamily = resolvePdfFontFamily(brand?.fontFamily); const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null; const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id); const exportedAt = new Date().toLocaleString(); const styles = StyleSheet.create({ - page: { padding: 40, fontSize: 11, fontFamily: 'Helvetica', color: '#111' }, + page: { padding: 40, fontSize: 11, fontFamily, color: '#111' }, header: { marginBottom: 24 }, logo: { height: 40, width: 'auto', marginBottom: 8, objectFit: 'contain' }, title: { fontSize: 22, fontWeight: 'bold', marginBottom: 4, color: accent }, diff --git a/src/components/settings/BrandingForm.tsx b/src/components/settings/BrandingForm.tsx index 2f0694cc..94dc947c 100644 --- a/src/components/settings/BrandingForm.tsx +++ b/src/components/settings/BrandingForm.tsx @@ -6,8 +6,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { getFreighterPublicKey } from "@/lib/freighter"; import { ALLOWED_LOGO_MIME_TYPES, + BRAND_FONT_OPTIONS, brandSettingsSchema, DEFAULT_ACCENT_COLOR, + getBrandFontCssFamily, isAllowedLogoMimeType, isLogoSizeOk, LOGO_SIZE_ERROR, @@ -25,7 +27,7 @@ import { uploadBrandLogo, } from "@/lib/branding"; -const EMPTY_VALUES = { logoUrl: "", accentColor: "", tagline: "" } as const; +const EMPTY_VALUES = { logoUrl: "", accentColor: "", tagline: "", fontFamily: "" } as const; const MAX_LOGO_MB = MAX_LOGO_BYTES / (1024 * 1024); @@ -72,6 +74,7 @@ export default function BrandingForm() { logoUrl: settings.logoUrl ?? "", accentColor: settings.accentColor ?? "", tagline: settings.tagline ?? "", + fontFamily: settings.fontFamily ?? "", }); }) .catch(() => { @@ -88,6 +91,8 @@ export default function BrandingForm() { const logoUrl = watch("logoUrl") ?? ""; const accentColor = watch("accentColor") ?? ""; const tagline = watch("tagline") ?? ""; + const fontFamily = watch("fontFamily") ?? ""; + const previewFontFamily = getBrandFontCssFamily(fontFamily || null); // Real-time contrast feedback: warn inline as soon as the typed/picked // color fails WCAG AA against white — before the user even hits save. @@ -140,11 +145,13 @@ export default function BrandingForm() { logoUrl: data.logoUrl ?? null, accentColor: data.accentColor ?? null, tagline: data.tagline ?? null, + fontFamily: data.fontFamily ?? null, }); reset({ logoUrl: saved.logoUrl ?? "", accentColor: saved.accentColor ?? "", tagline: saved.tagline ?? "", + fontFamily: saved.fontFamily ?? "", }); setFeedback({ kind: "success", text: "Branding saved — it now applies to all of your invoices." }); } catch (err) { @@ -341,12 +348,40 @@ export default function BrandingForm() { + {/* ── Font ─────────────────────────────────────────────────────────── */} +
+

+ Invoice font +

+

+ Applied to your invoice page preview and exported PDFs. +

+ + {errors.fontFamily && ( +

+ {errors.fontFamily.message} +

+ )} +
+ {/* ── Live preview ─────────────────────────────────────────────────── */}

Invoice preview

-
+
{logoUrl ? ( // eslint-disable-next-line @next/next/no-img-element -- user-supplied CDN URL diff --git a/src/lib/brandSettings.ts b/src/lib/brandSettings.ts index ba286458..81160ba0 100644 --- a/src/lib/brandSettings.ts +++ b/src/lib/brandSettings.ts @@ -18,6 +18,31 @@ export const LOGO_MIME_EXTENSION: Record = { /** Max characters for the invoice tagline. */ export const MAX_TAGLINE_LENGTH = 120; +/** + * Font choices for invoice PDFs/previews. Values are react-pdf's built-in + * standard fonts (no custom font embedding needed), with a matching CSS + * font-family for the on-screen preview. + */ +export const BRAND_FONT_OPTIONS = [ + { value: "Helvetica", label: "Helvetica (default)", cssFontFamily: "Helvetica, Arial, sans-serif" }, + { value: "Times-Roman", label: "Times Roman (serif)", cssFontFamily: "'Times New Roman', Times, serif" }, + { value: "Courier", label: "Courier (monospace)", cssFontFamily: "'Courier New', Courier, monospace" }, +] as const; + +export type BrandFont = (typeof BRAND_FONT_OPTIONS)[number]["value"]; + +const BRAND_FONT_VALUES = BRAND_FONT_OPTIONS.map((o) => o.value) as readonly string[]; + +/** True when the value is one of the supported invoice fonts. */ +export function isBrandFont(value: string): value is BrandFont { + return BRAND_FONT_VALUES.includes(value); +} + +/** Resolves the CSS font-family stack for a brand font (falls back to the default). */ +export function getBrandFontCssFamily(font: string | null | undefined): string { + return BRAND_FONT_OPTIONS.find((o) => o.value === font)?.cssFontFamily ?? BRAND_FONT_OPTIONS[0].cssFontFamily; +} + export const LOGO_TYPE_ERROR = "Unsupported file type. Upload a PNG, JPEG, or WebP image."; export const LOGO_SIZE_ERROR = "Logo exceeds the 2 MB size limit."; @@ -40,6 +65,7 @@ export interface BrandSettings { logoUrl: string | null; accentColor: string | null; tagline: string | null; + fontFamily: BrandFont | null; updatedAt: string; } @@ -48,6 +74,7 @@ export const DEFAULT_BRAND_SETTINGS: Omit = { logoUrl: null, accentColor: null, tagline: null, + fontFamily: null, }; /** Platform default accent used when no accent color is configured. */ @@ -108,6 +135,11 @@ export const brandSettingsSchema = z.object({ .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), + fontFamily: z + .union([z.literal(""), z.string().refine(isBrandFont, { message: "Unsupported font selection" })]) + .nullable() + .transform((v) => (v === "" ? null : (v as BrandFont | null) ?? null)) + .optional(), }); export type BrandSettingsInput = z.input; diff --git a/src/lib/brandSettingsStore.ts b/src/lib/brandSettingsStore.ts index e5ebb2f2..9887e0ad 100644 --- a/src/lib/brandSettingsStore.ts +++ b/src/lib/brandSettingsStore.ts @@ -43,13 +43,19 @@ export function hasBrandSettings(address: string): boolean { */ export function saveBrandSettings( address: string, - settings: { logoUrl: string | null; accentColor: string | null; tagline: string | null }, + settings: { + logoUrl: string | null; + accentColor: string | null; + tagline: string | null; + fontFamily: BrandSettings["fontFamily"]; + }, ): BrandSettings { const key = normalizeAddress(address); const record: BrandSettings = { logoUrl: settings.logoUrl, accentColor: settings.accentColor, tagline: settings.tagline, + fontFamily: settings.fontFamily, updatedAt: new Date().toISOString(), }; brandStore.set(key, record); diff --git a/src/lib/branding.ts b/src/lib/branding.ts index f8ce79f4..d9024165 100644 --- a/src/lib/branding.ts +++ b/src/lib/branding.ts @@ -20,6 +20,7 @@ function readCache(address: string): BrandSettings | null { logoUrl: parsed.logoUrl ?? null, accentColor: parsed.accentColor ?? null, tagline: parsed.tagline ?? null, + fontFamily: parsed.fontFamily ?? null, updatedAt: parsed.updatedAt ?? "", }; } @@ -68,7 +69,12 @@ export async function fetchBrandSettings(address: string): Promise { const res = await fetch("/api/settings/branding", { method: "PUT", From c62f196dccb27b42d3659631b00e8e5ba5752ff4 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Tue, 20 Oct 2026 01:40:42 +0100 Subject: [PATCH 2/2] Fix #897: Add Cmd/Ctrl+Enter shortcut to invoice creation wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ShortcutRegistry already covers navigation chords and the help overlay, but the invoice creation flow — the primary task in the app — had no keyboard shortcut to submit/advance. Registers Cmd/Ctrl+Enter via the existing useRegisterShortcuts pattern, reusing handleSubmit's existing step-advance-vs-create logic through form.requestSubmit(). --- src/app/invoice/new/page.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 5960f6c3..5f01f216 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, Suspense } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef, Suspense } from "react"; +import { useRegisterShortcuts } from "@/context/ShortcutRegistry"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import Stepper, { type Step } from "@/components/ui/Stepper"; @@ -652,6 +653,29 @@ function NewInvoiceForm() { }; }; + const formRef = useRef(null); + + // Cmd/Ctrl+Enter from anywhere in the wizard submits the form — on the + // review step this creates the invoice, on earlier steps it advances to + // the next step (handleSubmit already implements that distinction). + useRegisterShortcuts( + [ + { + id: "invoice-new:submit", + keys: ["Cmd", "Enter"], + description: "Submit / go to next step", + group: "Invoices", + handler: (e) => { + if (e.key !== "Enter") return; + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return; + e.preventDefault(); + formRef.current?.requestSubmit(); + }, + }, + ], + [], + ); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Pressing Enter inside a field on an earlier step should advance the @@ -1485,7 +1509,7 @@ function NewInvoiceForm() {

)} -
+ {step === 0 && !loading && renderBasicInfo()} {step === 1 && !loading && renderRecipients()} {step === 2 && !loading && renderOptions()}