Add business logo branding support
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
import { db } from "~/server/db";
|
||||
import { businesses } from "~/server/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
|
||||
|
||||
// Intentionally unauthenticated: a business logo must be viewable on public,
|
||||
// token-based invoice pages without a session. Business IDs are random
|
||||
// UUIDs, so this only serves images to callers who already know the ID.
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ businessId: string }> },
|
||||
) {
|
||||
const { businessId } = await params;
|
||||
const business = await db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, businessId),
|
||||
columns: { logoStorageKey: true, logoMimeType: true },
|
||||
});
|
||||
|
||||
if (!business?.logoStorageKey || !business.logoMimeType) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||
const wantsPng =
|
||||
new URL(req.url).searchParams.get("format") === "png" &&
|
||||
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
||||
|
||||
try {
|
||||
const body = await getObject(business.logoStorageKey);
|
||||
|
||||
if (wantsPng) {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const isSvg = business.logoMimeType === "image/svg+xml";
|
||||
// SVG is vector: rasterize at a high density so the PNG stays crisp at
|
||||
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||
// case — for SVG it would cap us at whatever tiny canvas the source's
|
||||
// intrinsic viewBox implies, even though the vector has no such limit.
|
||||
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
|
||||
.resize({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
fit: "inside",
|
||||
withoutEnlargement: !isSvg,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
return new NextResponse(new Uint8Array(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
headers: {
|
||||
"Content-Type": business.logoMimeType,
|
||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[business-logo] Failed to serve logo", {
|
||||
backendError: error,
|
||||
businessId,
|
||||
wantsPng,
|
||||
});
|
||||
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
@@ -16,7 +16,28 @@ export async function GET(
|
||||
where: eq(invoices.publicToken, token),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
// Explicit allowlist: token-based public route — never fetch
|
||||
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
|
||||
business: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
state: true,
|
||||
postalCode: true,
|
||||
country: true,
|
||||
website: true,
|
||||
taxId: true,
|
||||
logoStorageKey: true,
|
||||
logoMimeType: true,
|
||||
hideNameWithLogo: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
orderBy: (i, { asc }) => [
|
||||
asc(i.date),
|
||||
@@ -39,19 +60,27 @@ export async function GET(
|
||||
where: eq(platformSettings.id, "global"),
|
||||
});
|
||||
|
||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
});
|
||||
const pdfBlob = await generateInvoicePDFBlob(
|
||||
invoice,
|
||||
{
|
||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
},
|
||||
{ logoBaseUrl: new URL(request.url).origin },
|
||||
);
|
||||
|
||||
const buffer = await pdfBlob.arrayBuffer();
|
||||
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
|
||||
|
||||
@@ -74,9 +74,20 @@ export default async function BusinessDetailPage({
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
{business.logoStorageKey ? (
|
||||
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt={`${business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Business {
|
||||
website: string | null;
|
||||
taxId: string | null;
|
||||
logoUrl: string | null;
|
||||
logoStorageKey: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
@@ -86,8 +87,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||
{business.logoStorageKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
||||
<img
|
||||
src={`/api/business-logo/${business.id}`}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{business.name}</p>
|
||||
|
||||
@@ -377,6 +377,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.business.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business.id}`}
|
||||
alt={`${invoice.business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
|
||||
@@ -204,9 +204,12 @@ export default function SendEmailPage() {
|
||||
: undefined,
|
||||
business: invoiceData.business
|
||||
? {
|
||||
id: invoiceData.business.id,
|
||||
name: invoiceData.business.name,
|
||||
nickname: invoiceData.business.nickname,
|
||||
email: invoiceData.business.email,
|
||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||
logoMimeType: invoiceData.business.logoMimeType,
|
||||
}
|
||||
: undefined,
|
||||
items: invoiceData.items?.map((item) => ({
|
||||
|
||||
@@ -92,6 +92,8 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name
|
||||
: null;
|
||||
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
||||
@@ -99,11 +101,22 @@ function PublicInvoiceView({ token }: { token: string }) {
|
||||
{/* Card */}
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900 px-8 py-6">
|
||||
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||
{invoice.business?.email && (
|
||||
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p>
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||
{hasLogo && (
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business!.id}`}
|
||||
alt=""
|
||||
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{!hideName && (
|
||||
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||
)}
|
||||
{invoice.business?.email && (
|
||||
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
+2
-14
@@ -8,6 +8,7 @@ import { getAppUrl } from "~/lib/app-url";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||
@@ -63,20 +64,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
id="appearance-init"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||
var colorMode = stored.colorMode || "system";
|
||||
var root = document.documentElement;
|
||||
root.dataset.colorMode = colorMode;
|
||||
if (colorMode === "dark") root.classList.add("dark");
|
||||
} catch {}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<AppearanceInitScript />
|
||||
</head>
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<BrandBackground />
|
||||
|
||||
Reference in New Issue
Block a user