Add business logo branding support

This commit is contained in:
2026-08-14 16:26:43 -04:00
parent 3f3b1362a9
commit 29d7b498ae
31 changed files with 974 additions and 154 deletions
+72 -13
View File
@@ -75,6 +75,7 @@ export interface InvoiceData {
currency?: string | null;
notes?: string | null;
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
@@ -87,6 +88,9 @@ export interface InvoiceData {
country?: string | null;
website?: string | null;
taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null;
} | null;
client?: {
name: string;
@@ -843,14 +847,45 @@ function getColumnWidths(showRate: boolean) {
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
}
// @react-pdf/renderer's Image component only reliably decodes PNG/JPEG.
// SVG and WebP logos are rasterized to PNG on the fly by the serving route
// (via ?format=png, using sharp) so every logo format still shows in the PDF.
function resolveBusinessLogoSrc(
business: InvoiceData["business"],
baseUrlOverride?: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
if (typeof window !== "undefined") {
return `${window.location.origin}${path}`;
}
// Server-side rendering has no window.location — callers that know their
// own request origin (e.g. a Route Handler) should pass baseUrlOverride,
// since NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL can drift from the port the
// server actually ends up running on (e.g. in local dev).
const base =
baseUrlOverride ??
process.env.NEXT_PUBLIC_APP_URL ??
process.env.BETTER_AUTH_URL;
return base ? `${base.replace(/\/$/, "")}${path}` : null;
}
// Dense header component (first page)
const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
const logoSrc = resolveBusinessLogoSrc(invoice.business, logoBaseUrl);
const hideName = Boolean(logoSrc && invoice.business?.hideNameWithLogo);
return (
<View
@@ -860,15 +895,30 @@ const DenseHeader: React.FC<{
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
>
<View style={styles.businessSection}>
<Text
style={[
styles.businessName,
isMinimal ? minimalStyles.businessName : {},
{ color: settings.pdfAccentColor },
]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
{logoSrc && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src={logoSrc}
style={{
width: 160,
height: 64,
marginBottom: 6,
objectFit: "contain",
objectPosition: "left",
}}
/>
)}
{!hideName && (
<Text
style={[
styles.businessName,
isMinimal ? minimalStyles.businessName : {},
{ color: settings.pdfAccentColor },
]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
)}
{invoice.business?.email && (
<Text
style={[
@@ -1390,7 +1440,8 @@ const TotalsSection: React.FC<{
export const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings: inputSettings, logoBaseUrl }) => {
const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily,
@@ -1402,6 +1453,7 @@ export const InvoicePDF: React.FC<{
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
logoBaseUrl={logoBaseUrl}
/>
);
};
@@ -1410,7 +1462,8 @@ const InvoicePDFDocument: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD";
@@ -1426,6 +1479,7 @@ const InvoicePDFDocument: React.FC<{
>
<DenseHeader
invoice={invoice}
logoBaseUrl={logoBaseUrl}
settings={settings}
pdfStyles={pdfStyles}
/>
@@ -1592,6 +1646,7 @@ export async function generateInvoicePDF(
export async function generateInvoicePDFBlob(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
options?: { logoBaseUrl?: string },
): Promise<Blob> {
try {
// Validate invoice data
@@ -1609,7 +1664,11 @@ export async function generateInvoicePDFBlob(
// Generate PDF blob
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
<InvoicePDF
invoice={invoice}
settings={settings}
logoBaseUrl={options?.logoBaseUrl}
/>,
).toBlob();
// Validate blob