Archived
Add business logo branding support
This commit is contained in:
+72
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user