diff --git a/README.md b/README.md
index 07d2590..b581b40 100644
--- a/README.md
+++ b/README.md
@@ -308,6 +308,10 @@ The `/events` page renders server-supplied JSON payloads with performance safegu
The `/changelog` page keeps using `useApi("/api/v1/changelog")` for loading release notes. When the backend returns `{ entries: [] }`, it renders the shared `EmptyState` component with a clear "No changelog entries yet" message instead of an empty list. This branch is constant-time and adds no extra network calls.
+## Webhooks empty and loading states
+
+The `/webhooks` page shows the shared `Spinner` component during the initial fetch. Once the list resolves, if it is empty, it renders the `EmptyState` component with "No webhooks registered yet" and helpful guidance. When webhooks are present, they are rendered inside an accessible region for better screen-reader discovery.
+
## Formatting conventions
The frontend formats currency (Stroops / XLM) consistently using the helper `formatStroops` (located in `src/lib/format.ts`):
diff --git a/src/app/agents/page.test.tsx b/src/app/agents/page.test.tsx
index b5c99ae..e76143c 100644
--- a/src/app/agents/page.test.tsx
+++ b/src/app/agents/page.test.tsx
@@ -54,7 +54,10 @@ describe("AgentsPage", () => {
// --- Loading state --------------------------------------------------------
it("renders a spinner while the first page is loading", () => {
- mockByUrl({ agents: new Promise(() => undefined) /* never resolves */ });
+ mockByUrl({
+ agents: new Promise(() => undefined), // never resolves
+ stats: new Promise(() => undefined), // never resolves
+ });
render( );
diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx
index 87b27dd..5e08df8 100644
--- a/src/app/usage/page.tsx
+++ b/src/app/usage/page.tsx
@@ -63,6 +63,8 @@ export default function UsagePage() {
const parsed = parsePositiveInt(requests);
if (!parsed.ok) {
// Surface the validation message through the field error.
+ // We use a special kind 'validation-error' to distinguish it from API errors
+ // if we wanted to, but for now just making sure it's handled consistently.
setStatus({ kind: "error", message: parsed.message });
return;
}
@@ -143,7 +145,11 @@ export default function UsagePage() {
required
value={requests}
onChange={(e) => setRequests(e.target.value)}
- error={status.kind === "error" ? status.message : undefined}
+ error={
+ status.kind === "error" && !status.requestId
+ ? status.message
+ : undefined
+ }
/>
)}
- {status.kind === "error" && (
+ {status.kind === "error" && status.requestId && (
{formatAlert(status.message, status.requestId)}
diff --git a/src/app/webhooks/page.test.tsx b/src/app/webhooks/page.test.tsx
index 89a5154..6147ecd 100644
--- a/src/app/webhooks/page.test.tsx
+++ b/src/app/webhooks/page.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import WebhooksPage from "./page";
const mockItems = [
@@ -60,7 +60,8 @@ it("calls DELETE and closes dialog when confirmed", async () => {
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response);
fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
- const confirmBtn = screen.getAllByRole("button", { name: /^remove$/i })[0];
+ const dialog = screen.getByRole("dialog");
+ const confirmBtn = within(dialog).getByRole("button", { name: /^remove$/i });
fireEvent.click(confirmBtn);
await waitFor(() => {
@@ -69,3 +70,78 @@ it("calls DELETE and closes dialog when confirmed", async () => {
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
+
+it("shows loading spinner on initial load", async () => {
+ let resolveFetch: (value: unknown) => void;
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ globalThis.fetch = jest.fn().mockReturnValue(fetchPromise);
+
+ render( );
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ expect(screen.getByText(/loading webhooks/i)).toBeInTheDocument();
+
+ resolveFetch!({
+ ok: true,
+ status: 200,
+ json: async () => ({ items: [] }),
+ });
+ await screen.findByText(/no webhooks registered yet/i);
+});
+
+it("shows empty state when no webhooks are registered", async () => {
+ globalThis.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ items: [] }),
+ } as unknown as Response);
+
+ render( );
+ await screen.findByText(/no webhooks registered yet/i);
+ expect(screen.getByText(/register a webhook url to start receiving real-time event notifications/i)).toBeInTheDocument();
+});
+
+it("shows list inside accessible region when webhooks exist", async () => {
+ mockFetchSuccess();
+ render( );
+ await screen.findByText("https://example.com/hook");
+ expect(screen.getByRole("region", { name: /registered webhooks/i })).toBeInTheDocument();
+});
+
+it("handles creation error", async () => {
+ mockFetchSuccess();
+ render( );
+ await screen.findByText("https://example.com/hook");
+
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to create"));
+
+ fireEvent.change(screen.getByPlaceholderText(/https:\/\/example.com\/agentpay-hook/i), {
+ target: { value: "https://new.com/hook" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /register/i }));
+
+ await screen.findByText("Failed to create");
+});
+
+it("handles deletion error", async () => {
+ mockFetchSuccess();
+ render( );
+ await screen.findByText("https://example.com/hook");
+
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to delete"));
+
+ fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
+ // Target the confirm button inside the dialog specifically
+ const dialog = screen.getByRole("dialog");
+ const confirmBtn = within(dialog).getByRole("button", { name: /^remove$/i });
+ fireEvent.click(confirmBtn);
+
+ await screen.findByText("Failed to delete");
+});
+
+it("handles fetch error", async () => {
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to fetch"));
+ render( );
+ await screen.findByText("Failed to fetch");
+});
diff --git a/src/app/webhooks/page.tsx b/src/app/webhooks/page.tsx
index 1602964..6bd15d4 100644
--- a/src/app/webhooks/page.tsx
+++ b/src/app/webhooks/page.tsx
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { safeHref } from "@/lib/url";
+import { EmptyState } from "@/components/EmptyState";
+import { Spinner } from "@/components/Spinner";
type Webhook = { id: string; url: string; events: string[]; createdAt: number };
@@ -99,37 +101,50 @@ export default function WebhooksPage() {
)}
- {items && (
-
- {items.map((w) => (
-
-
-
- {(() => {
- const validated = safeHref(w.url);
- if (validated.ok) {
- return (
-
- {w.url}
-
- );
- }
- return w.url;
- })()}
+ {items === null && !error && (
+
+
+
+ )}
+ {items?.length === 0 && (
+
+ )}
+ {items && items.length > 0 && (
+
+
+ {items.map((w) => (
+
+
+
+ {(() => {
+ const validated = safeHref(w.url);
+ if (validated.ok) {
+ return (
+
+ {w.url}
+
+ );
+ }
+ return w.url;
+ })()}
-
-
{w.events.join(", ")}
-
- setPendingRemove(w)}
- className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
- >
- Remove
-
-
- ))}
-
+
+ {w.events.join(", ")}
+
+ setPendingRemove(w)}
+ className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
+ >
+ Remove
+
+
+ ))}
+
+
)}
);
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx
index c845d5c..e1f5e96 100644
--- a/src/components/ConfirmDialog.tsx
+++ b/src/components/ConfirmDialog.tsx
@@ -140,17 +140,19 @@ export function ConfirmDialog({
if (!open) return null;
return (
-
+
{title}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 6160619..9d32e77 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -72,13 +72,15 @@ function MobileNav({
useEffect(() => {
if (menuOpen) {
- const first = panelRef.current?.querySelector
(
+ const first = panelRef.current!.querySelector(
"a[role='menuitem'], a, [role='menuitem']"
);
- first?.focus?.();
+ if (first) {
+ first.focus();
+ }
return;
}
- toggleRef.current?.focus?.();
+ toggleRef.current!.focus();
}, [menuOpen]);
return (
diff --git a/src/components/KeyValueGrid.tsx b/src/components/KeyValueGrid.tsx
index c02076d..c7fe971 100644
--- a/src/components/KeyValueGrid.tsx
+++ b/src/components/KeyValueGrid.tsx
@@ -10,8 +10,20 @@ export function KeyValueGrid({ rows }: { rows: Row[] }) {
{rows.map((r, i) => (
-
{r.label}
- {r.value}
+
+ {r.label}
+
+
+ {r.value}
+
))}
diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx
index c55fdb3..0812ae4 100644
--- a/src/components/__tests__/Header.test.tsx
+++ b/src/components/__tests__/Header.test.tsx
@@ -155,5 +155,177 @@ describe("Header", () => {
const homeLink = screen.getByRole("link", { name: "Home" });
expect(homeLink.className).toContain("focus-visible:outline");
});
+
+ it("mobile menu manages focus when opening and closing", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ const toggle = getMobileToggle();
+ fireEvent.click(toggle);
+
+ // Should focus the first menu item (Home)
+ expect(screen.getByRole("menuitem", { name: "Home" })).toHaveFocus();
+
+ // Close it
+ fireEvent.click(toggle);
+ expect(toggle).toHaveFocus();
+ });
+
+ it("closes the desktop More menu on blur when focus leaves the menu", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ const moreBtn = screen.getByRole("button", { name: /more/i });
+ fireEvent.click(moreBtn);
+
+ const menu = screen.getByRole("menu");
+ expect(menu).toBeInTheDocument();
+
+ // Blur from the menu to something else
+ fireEvent.blur(menu, { relatedTarget: document.body });
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("closes the mobile menu when a secondary link is clicked", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ fireEvent.click(getMobileToggle());
+ const webhooksLink = screen.getByRole("menuitem", { name: "Webhooks" });
+ fireEvent.click(webhooksLink);
+
+ expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument();
+ });
+
+ it("closes the mobile menu when a primary link is clicked", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ fireEvent.click(getMobileToggle());
+ const servicesLink = screen.getByRole("menuitem", { name: "Services" });
+ fireEvent.click(servicesLink);
+
+ expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument();
+ });
+
+ it("closes the desktop More menu when a link inside it is clicked", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ const moreBtn = screen.getByRole("button", { name: /more/i });
+ fireEvent.click(moreBtn);
+
+ const apiKeysLink = screen.getByRole("menuitem", { name: "API Keys" });
+ fireEvent.click(apiKeysLink);
+
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("marks the active secondary route with aria-current in mobile menu", () => {
+ mockPathname.mockReturnValue("/api-keys");
+ render();
+ fireEvent.click(getMobileToggle());
+ expect(screen.getByRole("menuitem", { name: "API Keys" })).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ });
+
+ it("keeps the desktop More menu open when focus moves within it", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /more/i }));
+
+ const menu = screen.getByRole("menu");
+ const items = screen.getAllByRole("menuitem");
+ const secondItem = items[1];
+
+ fireEvent.blur(menu, { relatedTarget: secondItem });
+ expect(menu).toBeInTheDocument();
+ });
+
+ it("isActive returns false for Home link when on another page", () => {
+ mockPathname.mockReturnValue("/services");
+ render();
+ expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("aria-current");
+ });
+
+ it("isActive returns false for similar but different routes", () => {
+ mockPathname.mockReturnValue("/services-more");
+ render();
+ expect(screen.getByRole("link", { name: "Services" })).not.toHaveAttribute("aria-current");
+ });
+
+ it("isActive handles trailing slashes", () => {
+ mockPathname.mockReturnValue("/services/");
+ render();
+ expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page");
+ });
+
+ it("marks a deep secondary route with aria-current", () => {
+ mockPathname.mockReturnValue("/api-keys/new");
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /more/i }));
+ expect(screen.getByRole("menuitem", { name: "API Keys" })).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ });
+
+ it("mobile menu does not close on other key presses", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ const toggle = getMobileToggle();
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+
+ fireEvent.keyDown(window, { key: "Enter" });
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("marks active routes in mobile menu", () => {
+ mockPathname.mockReturnValue("/usage");
+ render();
+ fireEvent.click(getMobileToggle());
+
+ const usageLink = screen.getByRole("menuitem", { name: "Usage" });
+ expect(usageLink).toHaveAttribute("aria-current", "page");
+ });
+
+ it("closes the desktop More menu on route change", () => {
+ mockPathname.mockReturnValue("/");
+ const { rerender } = render();
+
+ const moreBtn = screen.getByRole("button", { name: /more/i });
+ fireEvent.click(moreBtn);
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+
+ mockPathname.mockReturnValue("/usage");
+ rerender();
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("closes the desktop More menu when focused out to nothing", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /more/i }));
+
+ const menu = screen.getByRole("menu");
+ fireEvent.blur(menu, { relatedTarget: null });
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("toggles the desktop More menu when clicked multiple times", () => {
+ mockPathname.mockReturnValue("/");
+ render();
+
+ const moreBtn = screen.getByRole("button", { name: /more/i });
+ fireEvent.click(moreBtn);
+ expect(moreBtn).toHaveAttribute("aria-expanded", "true");
+
+ fireEvent.click(moreBtn);
+ expect(moreBtn).toHaveAttribute("aria-expanded", "false");
+ });
});
diff --git a/src/components/__tests__/Pagination.test.tsx b/src/components/__tests__/Pagination.test.tsx
index 931c54d..8033e4f 100644
--- a/src/components/__tests__/Pagination.test.tsx
+++ b/src/components/__tests__/Pagination.test.tsx
@@ -25,4 +25,11 @@ describe("Pagination", () => {
fireEvent.click(screen.getByRole("button", { name: /previous/i }));
expect(onChange).toHaveBeenCalledWith(1);
});
+
+ it("calls onChange(1) when Previous is clicked on page 2", () => {
+ const onChange = jest.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /previous/i }));
+ expect(onChange).toHaveBeenCalledWith(1);
+ });
});
diff --git a/src/lib/validateNumber.ts b/src/lib/validateNumber.ts
index a3c2537..8af021a 100644
--- a/src/lib/validateNumber.ts
+++ b/src/lib/validateNumber.ts
@@ -24,8 +24,11 @@ const DEFAULT_POSITIVE_MESSAGE = "requests must be a positive integer";
* Rejected examples: "", "-1", "-0", "1.5", "1e2" (non-integer), "-0.1"
*/
export function parseNonNegativeInt(input: string): ParseResult {
+ if (input.trim() === "") {
+ return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE };
+ }
const n = Number(input);
- if (!Number.isInteger(n) || n < 0) {
+ if (!Number.isInteger(n) || n < 0 || Object.is(n, -0)) {
return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE };
}
return { ok: true, value: n };
@@ -38,6 +41,9 @@ export function parseNonNegativeInt(input: string): ParseResult {
* Rejected examples: "", "0", "-1", "1.5", "-0.1"
*/
export function parsePositiveInt(input: string): ParseResult {
+ if (input.trim() === "") {
+ return { ok: false, message: DEFAULT_POSITIVE_MESSAGE };
+ }
const n = Number(input);
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, message: DEFAULT_POSITIVE_MESSAGE };