diff --git a/README.md b/README.md
index 379ff03..b4a1219 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,10 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/
The `/api-keys` page lists each key label, prefix, and created-at age with the absolute ISO timestamp available on hover. If the account has no keys, the page renders a clear "No API keys yet" empty state instead of an empty list while preserving the create, reveal-once, copy, and revoke confirmation flows.
+### Export page notes
+
+The `/export` page builds JSON and CSV export requests from the resolved API base URL. Downloads are fetched as blobs so the UI can disable both buttons during an in-flight request, show the shared `Spinner` with a "Preparing export" status, trigger the browser download with the response filename, show a success toast, and surface backend failures in a `role="alert"` message.
+
## Shared components
See [docs/components.md](docs/components.md) for the shared component catalog,
diff --git a/src/app/export/ExportActions.tsx b/src/app/export/ExportActions.tsx
new file mode 100644
index 0000000..54affbb
--- /dev/null
+++ b/src/app/export/ExportActions.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { Spinner } from "@/components/Spinner";
+import { useToast } from "@/components/ToastProvider";
+import { useState } from "react";
+
+type ExportFormat = "json" | "csv";
+
+type Props = {
+ apiBase: string;
+};
+
+const buttonBase =
+ "rounded-full px-5 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500";
+
+function filenameFromDisposition(disposition: string | null, fallback: string) {
+ const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
+ if (!match) return fallback;
+
+ try {
+ return decodeURIComponent(match[1]);
+ } catch {
+ return fallback;
+ }
+}
+
+export function ExportActions({ apiBase }: Props) {
+ const [downloading, setDownloading] = useState(null);
+ const [error, setError] = useState(null);
+ const toast = useToast();
+
+ const startDownload = async (format: ExportFormat) => {
+ setError(null);
+ setDownloading(format);
+
+ try {
+ const response = await fetch(`${apiBase}/api/v1/usage/export.${format}`);
+ if (!response.ok) {
+ const body = await response.text().catch(() => "");
+ throw new Error(body || `Export failed with status ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filenameFromDisposition(
+ response.headers.get("content-disposition"),
+ `usage-export.${format}`,
+ );
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ toast.push(`${format.toUpperCase()} export downloaded.`, "info");
+ } catch (err) {
+ setError((err as Error).message || "Export failed");
+ } finally {
+ setDownloading(null);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {downloading && (
+
+
+ Preparing {downloading.toUpperCase()} export...
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/app/export/page.test.tsx b/src/app/export/page.test.tsx
new file mode 100644
index 0000000..82af6e2
--- /dev/null
+++ b/src/app/export/page.test.tsx
@@ -0,0 +1,118 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { ToastProvider } from "@/components/ToastProvider";
+import { ExportActions } from "./ExportActions";
+
+const originalCreateObjectURL = URL.createObjectURL;
+const originalRevokeObjectURL = URL.revokeObjectURL;
+let clickSpy: jest.SpyInstance;
+
+function renderExportActions() {
+ return render(
+
+
+ ,
+ );
+}
+
+function mockSuccessResponse(filename = "usage.json", content = "{}") {
+ return {
+ ok: true,
+ status: 200,
+ blob: async () => new Blob([content], { type: "application/json" }),
+ headers: {
+ get: (name: string) =>
+ name.toLowerCase() === "content-disposition"
+ ? `attachment; filename="${filename}"`
+ : null,
+ },
+ } as Response;
+}
+
+beforeEach(() => {
+ globalThis.fetch = jest.fn();
+ clickSpy = jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation();
+ URL.createObjectURL = jest.fn(() => "blob:usage-export");
+ URL.revokeObjectURL = jest.fn();
+});
+
+afterEach(() => {
+ clickSpy.mockRestore();
+ URL.createObjectURL = originalCreateObjectURL;
+ URL.revokeObjectURL = originalRevokeObjectURL;
+});
+
+describe("ExportActions", () => {
+ it("downloads the selected export, uses the response filename, and shows a toast", async () => {
+ (globalThis.fetch as jest.Mock).mockResolvedValueOnce(
+ mockSuccessResponse("usage.csv"),
+ );
+
+ renderExportActions();
+
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
+
+ await waitFor(() => {
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "https://api.example.test/api/v1/usage/export.csv",
+ );
+ expect(clickSpy).toHaveBeenCalled();
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:usage-export");
+ });
+ expect(await screen.findByText("CSV export downloaded.")).toBeInTheDocument();
+ });
+
+ it("disables both download buttons while a request is pending", async () => {
+ let resolveResponse: (response: Response) => void = () => {};
+ (globalThis.fetch as jest.Mock).mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveResponse = resolve;
+ }),
+ );
+
+ renderExportActions();
+
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
+
+ expect(screen.getByRole("button", { name: /downloading json/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Download CSV" })).toBeDisabled();
+ expect(screen.getByText("Preparing JSON export...")).toBeInTheDocument();
+ expect(screen.getByRole("status")).toHaveTextContent("Preparing JSON export");
+
+ resolveResponse(mockSuccessResponse());
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Download JSON" })).toBeEnabled();
+ });
+ });
+
+ it("shows backend errors to the operator", async () => {
+ (globalThis.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ text: async () => "export service unavailable",
+ } as Response);
+
+ renderExportActions();
+
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "export service unavailable",
+ );
+ });
+
+ it("still downloads an empty export response", async () => {
+ (globalThis.fetch as jest.Mock).mockResolvedValueOnce(
+ mockSuccessResponse("usage-empty.json", ""),
+ );
+
+ renderExportActions();
+
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
+
+ await waitFor(() => {
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/export/page.tsx b/src/app/export/page.tsx
index a311556..ffff311 100644
--- a/src/app/export/page.tsx
+++ b/src/app/export/page.tsx
@@ -1,4 +1,5 @@
import { resolveApiBase } from "@/lib/resolveApiBase";
+import { ExportActions } from "./ExportActions";
const API_BASE = resolveApiBase();
@@ -17,20 +18,7 @@ export default function ExportPage() {
Calls the backend export endpoints directly; the browser downloads the
file via Content-Disposition.
-
+
);
}