fix(invoicing): handle PDF uploads and improve error messaging

This commit addresses issues with PDF handling in the invoicing process. The application now correctly uploads the PDF returned from QuickBooks to the appropriate order item, ensuring that the PDF is available for preview. If no PDF is provided, the system will return a clear message to the user instead of displaying a blank frame.

Additionally, the `readQbWebhookInvoice` function has been updated to include a `pdfBase64` field, allowing for better handling of PDF data. The `PdfPreviewDialog` component has been modified to manage the display of the PDF or an informative message when the PDF is unavailable.

Tests have been added to verify the new functionality and ensure that the application behaves as expected when handling PDF uploads and errors.
This commit is contained in:
Dave 2026-10-09 13:27:17 +02:00
parent 6cfb413508
commit 841121097c
6 changed files with 333 additions and 50 deletions

View file

@ -1817,10 +1817,15 @@ export default function DeliveryNotesPage() {
{/*
The invoice that was just created, shown in place so she can
print or download it without leaving the list — the same
affordance the quote PDF popup gives. The file is already in
PocketBase: n8n downloads the QuickBooks PDF and attaches it
to the note's order_items as `qb_invoice_pdf`, so this is
just surfacing a file that exists, not a new integration.
affordance the quote PDF popup gives. The PDF is stored on one
of the note's order_items as `qb_invoice_pdf` by the invoice
route (which decodes the base64 n8n returns), so this is just
surfacing a file that exists, not a new integration.
`null` rather than `""` when there is no file: an empty `src`
makes Chrome reload the current page into the frame, which the
operator sees as a blank white screen. The dialog explains the
absence instead.
*/}
{createdInvoice && (
<PdfPreviewDialog
@ -1830,9 +1835,9 @@ export default function DeliveryNotesPage() {
if (!v) setCreatedInvoice(null);
}}
pdfUrl={
createdInvoice.pdfFileName
createdInvoice.pdfFileName && createdInvoice.pdfRecordId
? `${process.env.NEXT_PUBLIC_POCKETBASE_URL}/api/files/order_items/${createdInvoice.pdfRecordId}/${createdInvoice.pdfFileName}`
: ""
: null
}
title={`Invoice ${createdInvoice.number}`}
subtitle={
@ -1841,6 +1846,10 @@ export default function DeliveryNotesPage() {
: createdInvoice.date
}
downloadFilename={`Invoice-${createdInvoice.number}.pdf`}
// The invoice WAS created — this is only about the PDF not
// coming back with it. Say so plainly so she doesn't think
// the invoice failed and go create a second one.
missingMessage={`QuickBooks created invoice ${createdInvoice.number}, but no PDF came back with it. You can still print it from the Invoices tab.`}
/>
)}
@ -1986,11 +1995,12 @@ export default function DeliveryNotesPage() {
? `/api/invoicing/delivery-note-pdf?deliveryNoteNumber=${encodeURIComponent(
previewDnNumber,
)}`
: ""
: null
}
title={previewDnNumber ?? ""}
subtitle="Delivery note"
downloadFilename={`${previewDnNumber ?? "delivery-note"}.pdf`}
missingMessage="This delivery note's PDF could not be generated. Try again, or ask an administrator to check the server logs."
/>
{/* Q-icon PDF preview. The Q icon on every row opens

View file

@ -889,11 +889,11 @@ describe("readQbWebhookInvoice", () => {
it("reads the n8n success response", () => {
expect(
readQbWebhookInvoice({ status: "success", invoice_number: "INV10983", invoice_id: "29790", qb_invoice_date: "2026-09-25" }),
).toEqual({ number: "INV10983", date: "2026-09-25" });
).toEqual({ number: "INV10983", date: "2026-09-25", pdfBase64: null });
});
it("falls back to today for the date", () => {
expect(readQbWebhookInvoice({ invoiceNumber: "INV1" }, "2026-09-26")).toEqual({ number: "INV1", date: "2026-09-26" });
expect(readQbWebhookInvoice({ invoiceNumber: "INV1" }, "2026-09-26")).toEqual({ number: "INV1", date: "2026-09-26", pdfBase64: null });
});
it("never treats the QuickBooks internal id as the invoice number", () => {
@ -901,4 +901,43 @@ describe("readQbWebhookInvoice", () => {
expect(readQbWebhookInvoice(null)).toBeNull();
expect(readQbWebhookInvoice({ invoice_number: " " })).toBeNull();
});
// The PDF is the whole reason this function grew a third field: a
// freshly-created invoice used to open a blank preview because n8n
// returns the PDF and the app discarded it.
describe("pdf_base64", () => {
const pdf = Buffer.from("%PDF-1.7\nfake\n%%EOF").toString("base64");
it("keeps a bare base64 payload", () => {
expect(readQbWebhookInvoice({ invoice_number: "INV1", pdf_base64: pdf })?.pdfBase64).toBe(pdf);
});
it("accepts the qb_invoice_pdf alias n8n's own write-back reads", () => {
expect(readQbWebhookInvoice({ invoice_number: "INV1", qb_invoice_pdf: pdf })?.pdfBase64).toBe(pdf);
});
it("strips a data: URI prefix so the caller can hand over raw bytes", () => {
const got = readQbWebhookInvoice({
invoice_number: "INV1",
pdf_base64: `data:application/pdf;base64,${pdf}`,
});
expect(got?.pdfBase64).toBe(pdf);
});
it("treats a missing PDF as normal — n8n sends null when it has none", () => {
expect(readQbWebhookInvoice({ invoice_number: "INV1", pdf_base64: null })?.pdfBase64).toBeNull();
expect(readQbWebhookInvoice({ invoice_number: "INV1", pdf_base64: "" })?.pdfBase64).toBeNull();
});
it("rejects a non-PDF payload rather than storing a broken file", () => {
// An error page or a truncated body would otherwise be written
// to PB and then fail to render — turning a clean "no PDF" state
// into a confusing broken one.
expect(
readQbWebhookInvoice({ invoice_number: "INV1", pdf_base64: "PGh0bWw+PGJvZHk+PC9ib2R5PjwvaHRtbD4=" })
?.pdfBase64,
).toBeNull();
expect(readQbWebhookInvoice({ invoice_number: "INV1", pdf_base64: "not-base64-at-all" })?.pdfBase64).toBeNull();
});
});
});

View file

@ -602,18 +602,41 @@ export function buildReconcileRows(
});
}
export interface QbWebhookInvoice {
number: string;
date: string;
/**
* The rendered invoice PDF as raw base64, when n8n supplied one.
*
* `Build Created Response` in the QuickBooks workflow threads
* `pdf_base64` straight through to us. It is optional — the workflow
* logs `has_pdf: false` and sends `null` when the "Extract from File"
* node produced no binary — so treat absence as normal, never as an
* error.
*
* We persist this ourselves rather than relying on n8n's own PB
* write-back: that node builds a `data:` URI string and assigns it to
* the `qb_invoice_pdf` FILE field, which PocketBase silently discards
* (a file field only accepts multipart uploads). That is why a
* freshly-created invoice had no PDF to preview even though the
* number, date and `qty_invoiced` all landed correctly.
*/
pdfBase64: string | null;
}
/**
* The customer invoice number + date from the n8n QuickBooks webhook
* response (`Finalize PocketBase Invoice Update` / `Build Existing
* Response` in "1 - WFDASH QUICKBOOKS TEST_NEXTJS"). Returns null when
* the response carries no invoice number — callers must then skip the
* reconcile rather than invent one. `invoice_id` / `qb_invoice_id` are
* QuickBooks' internal ids, NOT the invoice number, so they're ignored.
* The customer invoice number + date (and, when present, the rendered
* PDF) from the n8n QuickBooks webhook response (`Finalize PocketBase
* Invoice Update` / `Build Existing Response` in "1 - WFDASH
* QUICKBOOKS TEST_NEXTJS"). Returns null when the response carries no
* invoice number — callers must then skip the reconcile rather than
* invent one. `invoice_id` / `qb_invoice_id` are QuickBooks' internal
* ids, NOT the invoice number, so they're ignored.
*/
export function readQbWebhookInvoice(
body: unknown,
today: string = new Date().toISOString().slice(0, 10),
): { number: string; date: string } | null {
): QbWebhookInvoice | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const pick = (...keys: string[]) => {
@ -626,7 +649,40 @@ export function readQbWebhookInvoice(
const number = pick("invoice_number", "invoiceNumber");
if (!number) return null;
const date = pick("qb_invoice_date", "invoice_date", "invoiceDate").slice(0, 10) || today;
return { number, date };
// `qb_invoice_pdf` is an accepted alias: n8n's own write-back node
// reads `ctx.pdf_base64 ?? ctx.qb_invoice_pdf`, so some workflow
// versions name it that way on the way out too. Accept a bare base64
// payload or a full `data:` URI — we normalise to the former and let
// the caller hand the bytes straight to a multipart upload.
let pdfBase64 = pick("pdf_base64", "qb_invoice_pdf");
if (pdfBase64.startsWith("data:")) {
const comma = pdfBase64.indexOf(",");
pdfBase64 = comma === -1 ? "" : pdfBase64.slice(comma + 1);
}
// A real PDF always starts with "%PDF" — but that is the DECODED
// prefix. The base64 text itself begins "JVBERi…", so testing the
// encoded string against "%PDF" rejects every genuine PDF. Decode a
// short head and check the bytes. `atob` exists in both Node 16+ and
// the browser, so this stays safe in the client bundle.
//
// Why bother: a truncated body or an HTML error page would otherwise
// be written to PB and then fail to render — turning a working
// "no PDF" state into a confusing broken one.
if (pdfBase64 && !looksLikePdf(pdfBase64)) {
pdfBase64 = "";
}
return { number, date, pdfBase64: pdfBase64 || null };
}
function looksLikePdf(base64: string): boolean {
try {
// 16 is a multiple of 4 so `atob` won't trip on the padding.
return atob(base64.slice(0, 16)).startsWith("%PDF");
} catch {
return false;
}
}
/**

View file

@ -1,16 +1,17 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { reconcile, markCompleted, getOne, getFullList } = vi.hoisted(() => ({
const { reconcile, markCompleted, getOne, getFullList, update } = vi.hoisted(() => ({
reconcile: vi.fn(),
markCompleted: vi.fn(),
getOne: vi.fn(),
getFullList: vi.fn(),
update: vi.fn(),
}));
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({ getOne, getFullList }),
collection: () => ({ getOne, getFullList, update }),
}),
}));
vi.mock("@/lib/server/orders-service", () => ({ reconcileOrderItemInvoicedStatus: reconcile }));
@ -57,6 +58,7 @@ beforeEach(() => {
]);
reconcile.mockResolvedValue({});
markCompleted.mockResolvedValue({ id: "dn-1", status: "completed" });
update.mockResolvedValue({ qb_invoice_pdf: "abc123_Invoice-INV10990.pdf" });
});
describe("POST /api/v1/delivery-notes/[id]/invoice", () => {
@ -91,4 +93,71 @@ describe("POST /api/v1/delivery-notes/[id]/invoice", () => {
expect(res.status).toBe(200);
expect(reconcile).not.toHaveBeenCalled();
});
// The blank-preview bug. n8n returns `pdf_base64` but its own PB
// write-back can't store it (a file field rejects a plain string), so
// the app has to do the multipart upload itself — otherwise the
// operator gets an invoice that "worked" and an empty white frame.
describe("invoice PDF", () => {
const pdfB64 = Buffer.from("%PDF-1.7\nfake\n%%EOF").toString("base64");
it("uploads the returned PDF to an order_item and hands back the reference", async () => {
mockWebhook({ status: "success", invoice_number: "INV10990", qb_invoice_date: "2026-09-25", pdf_base64: pdfB64 });
const res = await POST(request(), params);
expect(res.status).toBe(200);
// `qb_invoice_pdf` is a FILE field — only multipart works.
const [itemId, form] = update.mock.calls[0];
expect(itemId).toBe("a");
expect(form).toBeInstanceOf(FormData);
const uploaded = form.get("qb_invoice_pdf") as File;
expect(uploaded.name).toBe("Invoice-INV10990.pdf");
expect(uploaded.type).toBe("application/pdf");
expect(Buffer.from(await uploaded.arrayBuffer()).toString()).toBe("%PDF-1.7\nfake\n%%EOF");
// PocketBase prefixes the stored name with a random token — the
// caller needs that exact string to build the file URL.
const body = (await res.json()) as { invoice: { pdfFileName: string; pdfRecordId: string } };
expect(body.invoice.pdfFileName).toBe("abc123_Invoice-INV10990.pdf");
expect(body.invoice.pdfRecordId).toBe("a");
});
it("does not re-upload when n8n already attached a PDF", async () => {
getFullList.mockResolvedValue([
{ id: "a", description: "M16 bolt", quantity: 10, qty_collected: 10, selling_price: 3, qb_invoice_pdf: "already_there.pdf" },
]);
mockWebhook({ status: "success", invoice_number: "INV10990", pdf_base64: pdfB64 });
const res = await POST(request(), params);
expect(update).not.toHaveBeenCalled();
const body = (await res.json()) as { invoice: { pdfFileName: string } };
expect(body.invoice.pdfFileName).toBe("already_there.pdf");
});
it("still succeeds when QuickBooks sends no PDF at all", async () => {
mockWebhook({ status: "success", invoice_number: "INV10990", pdf_base64: null });
const res = await POST(request(), params);
expect(res.status).toBe(200);
expect(update).not.toHaveBeenCalled();
const body = (await res.json()) as { invoice: { number: string; pdfFileName: null } };
// null, never "" — an empty string becomes an empty iframe src.
expect(body.invoice.number).toBe("INV10990");
expect(body.invoice.pdfFileName).toBeNull();
});
it("does not fail the invoice when the upload itself fails", async () => {
update.mockRejectedValueOnce(new Error("PB 500"));
mockWebhook({ status: "success", invoice_number: "INV10990", pdf_base64: pdfB64 });
const res = await POST(request(), params);
// The QuickBooks invoice exists; a failed PDF upload must not
// undo it or raise a 500 the operator would read as "no invoice".
expect(res.status).toBe(200);
const body = (await res.json()) as { invoice: { number: string; pdfFileName: null } };
expect(body.invoice.number).toBe("INV10990");
expect(body.invoice.pdfFileName).toBeNull();
});
});
});

View file

@ -40,6 +40,60 @@ interface QbWebhookResponse {
qb_invoice_id?: string;
id?: string;
invoice_number?: string;
/** Raw base64 of the rendered invoice PDF. Optional. */
pdf_base64?: string | null;
}
/**
* Persist the invoice PDF that n8n returned as base64 onto one of the
* delivery note's order_items, then return the reference the browser
* needs to stream it (`qb_invoice_pdf` is a PB FILE field, so the URL
* is `/api/files/order_items/<orderItemId>/<storedFileName>`).
*
* ## Why the app uploads it rather than trusting n8n
*
* n8n's own "Build PocketBase Invoice Update" node *tries* to write the
* PDF back: it builds `data:application/pdf;base64,…` and assigns it to
* `qb_invoice_pdf`. PocketBase silently drops that. A file field only
* accepts a multipart upload — a plain string assignment is a no-op,
* not an error. The visible symptom was an invoice that looked
* completely successful (number, date and `qty_invoiced` all landed)
* and then opened a blank preview, because the PDF had never been
* stored anywhere.
*
* Non-fatal by design: the QuickBooks invoice exists regardless. If
* this fails we log it, return `null` references, and the caller shows
* an explanation instead of a blank frame.
*/
async function storeInvoicePdf(input: {
pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
orderItemId: string;
base64: string;
invoiceNumber: string;
}): Promise<string | null> {
try {
const bytes = Buffer.from(input.base64, "base64");
if (bytes.byteLength === 0) return null;
const safeNumber = input.invoiceNumber.replace(/[^\w.-]/g, "_");
const filename = `Invoice-${safeNumber}.pdf`;
const form = new FormData();
form.append(
"qb_invoice_pdf",
new Blob([new Uint8Array(bytes)], { type: "application/pdf" }),
filename,
);
const updated = await input.pb
.collection("order_items")
.update<{ qb_invoice_pdf?: string | string[] }>(input.orderItemId, form);
const stored = updated.qb_invoice_pdf;
return Array.isArray(stored) ? (stored[0] ?? null) : (stored ?? null);
} catch (err) {
console.error(
`[POST /api/v1/delivery-notes/[id]/invoice] failed to store invoice PDF on order item ${input.orderItemId}:`,
err,
);
return null;
}
}
/**
@ -303,17 +357,30 @@ export async function POST(
// delivery-notes LIST show the invoice in a popup after creating it,
// instead of the operator having to go find it in QuickBooks.
//
// The PDF is not fetched here. n8n already downloads it and
// attaches it to the note's order_items as `qb_invoice_pdf` (1,200+
// rows carry one), so we hand back that reference and the browser
// prints it from PocketBase like any other file.
// `rowsBilled` is a shape-only projection for reconcile; it has no
// PDF field. Read the reference off the DN's own items.
// The PDF is a file on the ORDER_ITEM, not on the delivery note,
// so the browser needs that record's id to build the PB file URL
// (`/api/files/<collection>/<recordId>/<fileName>`).
const pdfItem = items.find((it) => it.qb_invoice_pdf);
const pdfRef = pdfItem?.qb_invoice_pdf ?? null;
// The PDF hangs off an ORDER_ITEM, not the delivery note, so the
// browser needs that record's id to build the PB file URL
// (`/api/files/<collection>/<recordId>/<fileName>`). Prefer one n8n
// already attached; otherwise store the base64 it just sent us.
const pdfItemId = items.find((it) => it.qb_invoice_pdf)?.id ?? items[0]?.id ?? null;
let pdfFileName = (() => {
const ref = items.find((it) => it.qb_invoice_pdf)?.qb_invoice_pdf;
return Array.isArray(ref) ? (ref[0] ?? null) : (ref ?? null);
})();
if (!pdfFileName && qbInvoice?.pdfBase64 && pdfItemId) {
pdfFileName = await storeInvoicePdf({
pb,
orderItemId: pdfItemId,
base64: qbInvoice.pdfBase64,
invoiceNumber: qbInvoice.number,
});
if (!pdfFileName) {
console.error(
`[POST /api/v1/delivery-notes/[id]/invoice] QuickBooks sent a PDF for ${qbInvoice.number} but it could not be stored; the preview will have no file.`,
);
}
}
return NextResponse.json({
...updated,
uncollectedLineCount: uncollected.length,
@ -324,9 +391,8 @@ export async function POST(
invoice: {
number: invoiceNumber,
date: qbInvoice?.date ?? new Date().toISOString().slice(0, 10),
// A single-file field is a bare string; several is an array.
pdfFileName: Array.isArray(pdfRef) ? (pdfRef[0] ?? null) : (pdfRef ?? null),
pdfRecordId: pdfItem?.id ?? null,
pdfFileName,
pdfRecordId: pdfItemId,
},
});
} catch (err: unknown) {

View file

@ -34,7 +34,7 @@
* detail page.
*/
import { useEffect, useRef, useState } from "react";
import { Download, Loader2, Printer, RefreshCw, X } from "lucide-react";
import { Download, FileWarning, Loader2, Printer, RefreshCw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -76,14 +76,29 @@ const PDF_DIALOG_SIZE =
export interface PdfPreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The full URL of the PDF to render in the iframe. */
pdfUrl: string;
/**
* The full URL of the PDF to render in the iframe, or `null` when no
* PDF is available.
*
* `null` is NOT the same as `""`. Passing `""` renders an iframe
* whose `src` is empty, which Chrome treats as "load the current
* page" — the operator gets a blank white frame plus a console
* warning. It is a bug, not a missing file, so the dialog refuses
* the empty string and explains the absence instead.
*/
pdfUrl: string | null;
/** Title shown in the dialog header (e.g. "DN26071401" or "Q202607001"). */
title: string;
/** Short subtitle shown under the title (e.g. "Delivery note" or "Quote"). */
subtitle?: string;
/** Filename used for the Download button's `download` attribute. */
downloadFilename: string;
/**
* Shown in place of the PDF when `pdfUrl` is null. Should tell the
* operator what IS true and what they can still do — a blank frame
* with no explanation is the failure this whole path exists to avoid.
*/
missingMessage?: string;
}
export function PdfPreviewDialog({
@ -93,12 +108,19 @@ export function PdfPreviewDialog({
title,
subtitle,
downloadFilename,
missingMessage,
}: PdfPreviewDialogProps) {
// Ref on the iframe so we can tell whether the PDF has finished
// loading before offering a print action.
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [blocked, setBlocked] = useState(false);
// An empty string is the dangerous case — Chrome reloads the page
// into the frame. Treat it as "no PDF" so the explanation shows
// instead. Every caller that can produce a missing file goes through
// here, so this is the single place the rule needs to hold.
const resolvedUrl = pdfUrl && pdfUrl.trim() ? pdfUrl : null;
// Clear the pop-up-blocked notice on close rather than in an effect:
// syncing state from an effect triggers a second render pass, and
// the flag is only ever set by a click inside this component.
@ -108,6 +130,7 @@ export function PdfPreviewDialog({
};
const handlePrint = () => {
if (!resolvedUrl) return;
// Deliberately opens the PDF in a new tab rather than calling
// `iframe.contentWindow.print()`.
//
@ -127,7 +150,7 @@ export function PdfPreviewDialog({
//
// A new tab hands the operator Chrome's real PDF viewer, whose own
// print button works. Same document, no blank pages.
const url = pdfUrl;
const url = resolvedUrl;
const win = window.open(url, "_blank", "noopener,noreferrer");
if (!win) {
// Popup blocked. Say so rather than appearing to do nothing.
@ -151,8 +174,8 @@ export function PdfPreviewDialog({
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
Close
</Button>
<a href={pdfUrl} download={downloadFilename}>
<Button type="button" variant="outline">
<a href={resolvedUrl ?? "#"} download={downloadFilename} aria-disabled={!resolvedUrl}>
<Button type="button" variant="outline" disabled={!resolvedUrl}>
<Download className="mr-1 h-3.5 w-3.5" />
Download instead
</Button>
@ -177,8 +200,10 @@ export function PdfPreviewDialog({
<div>
<DialogTitle className="font-mono">{title}</DialogTitle>
<DialogDescription>
{subtitle ? `${subtitle} · ` : ""}Use Print to send to a
printer, or Download to save a copy.
{subtitle ? `${subtitle} · ` : ""}
{resolvedUrl
? "Use Print to send to a printer, or Download to save a copy."
: "There is no file to print or download."}
</DialogDescription>
</div>
<Button
@ -200,13 +225,29 @@ export function PdfPreviewDialog({
served by our own API route (same origin), so
the X-Frame-Options risk is internal. */}
<div className="flex-1 bg-muted/30">
<iframe
ref={iframeRef}
src={pdfUrl}
title={title}
className="w-full h-full border-0"
data-testid="pdf-preview-iframe"
/>
{resolvedUrl ? (
<iframe
ref={iframeRef}
src={resolvedUrl}
title={title}
className="w-full h-full border-0"
data-testid="pdf-preview-iframe"
/>
) : (
// No PDF. Say why — a blank frame reads as "the app is
// broken", which is exactly the impression we are fixing.
<div
className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center"
data-testid="pdf-preview-missing"
>
<FileWarning className="h-6 w-6 text-muted-foreground" />
<p className="text-sm font-medium">No PDF to show</p>
<p className="max-w-md text-sm text-muted-foreground">
{missingMessage ??
"The file for this document isn't available right now."}
</p>
</div>
)}
</div>
<DialogFooter className="px-4 py-3 border-t gap-2">
@ -225,6 +266,7 @@ export function PdfPreviewDialog({
type="button"
variant="outline"
onClick={handlePrint}
disabled={!resolvedUrl}
data-testid="pdf-preview-print"
>
<Printer className="mr-1 h-3.5 w-3.5" />
@ -235,11 +277,12 @@ export function PdfPreviewDialog({
save the file rather than open it in a viewer.
Operator's "save a copy" path. */}
<a
href={pdfUrl}
href={resolvedUrl ?? "#"}
download={downloadFilename}
aria-disabled={!resolvedUrl}
data-testid="pdf-preview-download"
>
<Button type="button">
<Button type="button" disabled={!resolvedUrl}>
<Download className="mr-1 h-3.5 w-3.5" />
Download
</Button>