Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ control:
- `src/app/robots.ts` allows public crawling from `/` and explicitly
disallows operator-only dashboard surfaces: `/admin`, `/api-keys`,
`/webhooks`, and `/settings`.
- Both metadata routes derive absolute URLs from
- `src/app/manifest.ts` serves the PWA web app manifest with name, description, branding colors matching the dark/light palette in `src/app/globals.css`, and `favicon.ico` icon entry to enable installability as a PWA.
- Both `sitemap.ts` and `robots.ts` derive absolute URLs from
`NEXT_PUBLIC_AGENTPAY_SITE_ORIGIN`, defaulting to `http://localhost:3000` for
local development rather than hard-coding a production domain.

Expand Down
17 changes: 17 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ Use `Header` once in the app shell. It already exposes the nav with
<Footer />
```

### `PageShell`

| Prop | Type | Required | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes | Inner content of the page layout wrapper. |
| `maxWidth` | `"xl" \| "2xl" \| "3xl" \| "4xl" \| "5xl" \| "6xl" \| "7xl" \| string` | no | Suffix of the max-width Tailwind class (e.g. `"3xl"` sets `"max-w-3xl"`). Defaults to `"3xl"`. |
| `gap` | `"4" \| "6" \| "8" \| "12" \| string` | no | Suffix of the gap Tailwind class (e.g. `"6"` sets `"gap-6"`). Defaults to `"6"`. |
| `className` | `string` | no | Additional style classes to append. |

PageShell wraps pages inside the `<main id="main-content">` accessible landmark, providing consistent focus indicators for accessibility skip-links, min-height formatting, and horizontal auto-centering.

```tsx
<PageShell maxWidth="2xl" gap="8">
<h1>Page Title</h1>
</PageShell>
```

### `PageHeading`

| Prop | Type | Required | Notes |
Expand Down
10 changes: 4 additions & 6 deletions src/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PageShell } from "@/components/PageShell";

export const metadata = { title: "Docs — AgentPay" };

const sections = [
Expand Down Expand Up @@ -25,11 +27,7 @@ const sections = [

export default function DocsPage() {
return (
<main
id="main-content"
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
>
<PageShell maxWidth="3xl" gap="6">
<h1 className="text-3xl font-semibold tracking-tight">API documentation</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Companion to{" "}
Expand Down Expand Up @@ -57,6 +55,6 @@ export default function DocsPage() {
</div>
))}
</dl>
</main>
</PageShell>
);
}
16 changes: 15 additions & 1 deletion src/app/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { metadata } from "./layout";
import { metadata, viewport } from "./layout";

describe("root layout metadata", () => {
it("keeps the home route on the default AgentPay title", () => {
Expand All @@ -7,4 +7,18 @@ describe("root layout metadata", () => {
template: "%s — AgentPay",
});
});

it("configures the manifest and apple-touch icon in metadata", () => {
expect(metadata.manifest).toBe("/manifest.webmanifest");
expect(metadata.icons).toMatchObject({
apple: "/favicon.ico",
});
});

it("configures the themeColor in viewport", () => {
expect(viewport.themeColor).toEqual([
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
]);
});
});
13 changes: 12 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/Header";
Expand All @@ -16,6 +16,13 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});

export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};

export const metadata: Metadata = {
title: {
default: "AgentPay",
Expand All @@ -24,6 +31,10 @@ export const metadata: Metadata = {
description: "Machine-to-machine payment protocol on Stellar",
applicationName: "AgentPay",
authors: [{ name: "AgentPay" }],
manifest: "/manifest.webmanifest",
icons: {
apple: "/favicon.ico",
},
openGraph: {
title: "AgentPay",
description: "Pay-per-request billing for AI agents and APIs on Stellar.",
Expand Down
27 changes: 27 additions & 0 deletions src/app/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import manifest from "./manifest";

describe("manifest metadata route", () => {
it("returns a valid manifest configuration", () => {
const config = manifest();

// Required fields assertion
expect(config.name).toBe("AgentPay");
expect(config.short_name).toBe("AgentPay");
expect(config.description).toBe("Machine-to-machine payment protocol on Stellar");
expect(config.start_url).toBe("/");
expect(config.display).toBe("standalone");

// Colors matching globals.css dark/light palette
const allowedColors = ["#0a0a0a", "#ffffff"];
expect(allowedColors).toContain(config.background_color);
expect(allowedColors).toContain(config.theme_color);

// Icon entries check
expect(config.icons).toBeDefined();
expect(config.icons!.length).toBeGreaterThan(0);
const hasFavicon = config.icons!.some(
(icon) => icon.src === "/favicon.ico"
);
expect(hasFavicon).toBe(true);
});
});
20 changes: 20 additions & 0 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
return {
name: "AgentPay",
short_name: "AgentPay",
description: "Machine-to-machine payment protocol on Stellar",
start_url: "/",
display: "standalone",
background_color: "#0a0a0a",
theme_color: "#0a0a0a",
icons: [
{
src: "/favicon.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}
9 changes: 3 additions & 6 deletions src/app/services/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiPost } from "@/lib/apiClient";
import { PageShell } from "@/components/PageShell";

export default function NewServicePage() {
const router = useRouter();
Expand Down Expand Up @@ -31,11 +32,7 @@ export default function NewServicePage() {
};

return (
<main
id="main-content"
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none"
>
<PageShell maxWidth="xl" gap="6">
<h1 className="text-3xl font-semibold tracking-tight">New service</h1>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1 text-sm">
Expand Down Expand Up @@ -71,6 +68,6 @@ export default function NewServicePage() {
</p>
)}
</form>
</main>
</PageShell>
);
}
9 changes: 3 additions & 6 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { ThemeToggle } from "@/components/ThemeToggle";
import { PageShell } from "@/components/PageShell";

export const metadata = { title: "Settings — AgentPay" };

export default function SettingsPage() {
return (
<main
id="main-content"
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-2xl flex-col gap-8 p-8 focus:outline-none"
>
<PageShell maxWidth="2xl" gap="8">
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
<section className="flex flex-col gap-2">
<h2 className="text-lg font-medium">Appearance</h2>
Expand All @@ -17,6 +14,6 @@ export default function SettingsPage() {
</p>
<ThemeToggle />
</section>
</main>
</PageShell>
);
}
59 changes: 59 additions & 0 deletions src/components/PageShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from "react";

export interface PageShellProps {
children: React.ReactNode;
/**
* Tailwind max-width suffix (e.g. 'xl', '2xl', '3xl', '4xl').
* Defaults to '3xl'.
*/
maxWidth?: "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl" | string;
/**
* Tailwind gap suffix (e.g. '6', '8').
* Defaults to '6'.
*/
gap?: "4" | "6" | "8" | "12" | string;
/**
* Additional className to append/merge.
*/
className?: string;
}

/**
* PageShell component consolidates the repeated main wrapper layout
* used across pages to ensure consistent accessibility (skip link target,
* focus styling, page spacing) and design language.
*/
export function PageShell({
children,
maxWidth = "3xl",
gap = "6",
className = "",
}: PageShellProps) {
// Map standard maxWidth keys to class names safely to prevent dynamic class interpolation issues
const maxWidthClass = {
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
"5xl": "max-w-5xl",
"6xl": "max-w-6xl",
"7xl": "max-w-7xl",
}[maxWidth] || `max-w-${maxWidth}`;

// Map gap keys to class names safely
const gapClass = {
"4": "gap-4",
"6": "gap-6",
"8": "gap-8",
"12": "gap-12",
}[gap] || `gap-${gap}`;

const baseClasses = `mx-auto flex min-h-[60vh] ${maxWidthClass} flex-col ${gapClass} p-8 focus:outline-none`;
const mergedClasses = className ? `${baseClasses} ${className}` : baseClasses;

return (
<main id="main-content" tabIndex={-1} className={mergedClasses}>
{children}
</main>
);
}
90 changes: 90 additions & 0 deletions src/components/__tests__/PageShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { PageShell } from "../PageShell";
import DocsPage from "@/app/docs/page";
import SettingsPage from "@/app/settings/page";

const mockMatchMedia = (matches: boolean) => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
};

describe("PageShell Component", () => {
beforeEach(() => {
mockMatchMedia(false);
});

it("renders a main landmark with default options", () => {
render(<PageShell>Test Content</PageShell>);

const main = screen.getByRole("main");
expect(main).toBeInTheDocument();
expect(main).toHaveAttribute("id", "main-content");
expect(main).toHaveAttribute("tabIndex", "-1");
expect(main.className).toContain("mx-auto");
expect(main.className).toContain("min-h-[60vh]");
expect(main.className).toContain("max-w-3xl");
expect(main.className).toContain("gap-6");
expect(screen.getByText("Test Content")).toBeInTheDocument();
});

it("handles custom maxWidth values", () => {
const { rerender } = render(<PageShell maxWidth="xl">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("max-w-xl");

rerender(<PageShell maxWidth="2xl">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("max-w-2xl");

rerender(<PageShell maxWidth="7xl">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("max-w-7xl");

rerender(<PageShell maxWidth="custom-500">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("max-w-custom-500");
});

it("handles custom gap values", () => {
const { rerender } = render(<PageShell gap="8">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("gap-8");

rerender(<PageShell gap="12">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("gap-12");

rerender(<PageShell gap="16">Content</PageShell>);
expect(screen.getByRole("main").className).toContain("gap-16");
});

it("merges custom className prop", () => {
render(<PageShell className="custom-class-123">Content</PageShell>);
const main = screen.getByRole("main");
expect(main.className).toContain("custom-class-123");
expect(main.className).toContain("max-w-3xl"); // preserves default
});

it("renders migrated DocsPage content inside PageShell", () => {
render(<DocsPage />);
const main = screen.getByRole("main");
expect(main).toHaveAttribute("id", "main-content");
expect(main.className).toContain("max-w-3xl");
expect(screen.getByRole("heading", { name: /API documentation/i })).toBeInTheDocument();
});

it("renders migrated SettingsPage content inside PageShell", () => {
render(<SettingsPage />);
const main = screen.getByRole("main");
expect(main).toHaveAttribute("id", "main-content");
expect(main.className).toContain("max-w-2xl");
expect(screen.getByRole("heading", { name: /Settings/i })).toBeInTheDocument();
});
});
Loading