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
File renamed without changes.
5 changes: 2 additions & 3 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ for your environment. Documented variables include:
is disabled for normal development and builds.
- PWA metadata is exposed through `/manifest.webmanifest`; update
`app/manifest.ts` when icons or install behavior change.
- New user-facing copy should be added to `lib/i18n/messages/<locale>.json`
and read through the helpers in `lib/i18n` so additional locales can be added
without rewriting route components.
- Sentry client-side setup and API error reporting notes live in
[docs/escrow.md](../docs/escrow.md) at the repo root.

## Running Tests

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/admin/credits/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import dynamic from "next/dynamic";
// its own chunk and only fetched when an authenticated admin visits
// /admin/credits, so regular (non-admin) users never download it.
const AdminCreditsManagement = dynamic(
() => import("@/lib/AdminCreditsManagement"),
() => import("@/components/admin/credits-management"),
{
ssr: false,
loading: () => (
Expand Down
50 changes: 3 additions & 47 deletions frontend/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
Landmark,
Scale,
Split,
RefreshCw,
LayoutDashboard,
Coins,
} from "lucide-react";
import { Landmark } from "lucide-react";
import { Providers } from "@/lib/providers/providers";
import { AdminBreadcrumbs } from "@/components/admin/admin-breadcrumbs";
import { AdminNav } from "@/components/admin/admin-nav";
import { UserMenu } from "@/components/auth/user-menu";
import { cn } from "@/lib/utils";

export const metadata: Metadata = {
title: "Admin",
};

const nav = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/accounts", label: "Ledger accounts", icon: Landmark },
{ href: "/admin/credits", label: "Credits & payments", icon: Coins },
{ href: "/admin/integrity", label: "Integrity check", icon: Scale },
{ href: "/admin/splits", label: "Revenue splits", icon: Split },
{ href: "/admin/settlements", label: "Settlement", icon: RefreshCw },
];

export default function AdminLayout({
children,
}: {
Expand All @@ -50,13 +34,7 @@ export default function AdminLayout({
</header>
<div className="mx-auto flex max-w-7xl gap-8 px-6 py-8">
<aside className="w-52 shrink-0">
<nav className="sticky top-8 space-y-1">
{nav.map(({ href, label, icon: Icon }) => (
<AdminLink key={href} href={href} icon={Icon}>
{label}
</AdminLink>
))}
</nav>
<AdminNav />
</aside>
<main className="min-w-0 flex-1">
<AdminBreadcrumbs />
Expand All @@ -67,25 +45,3 @@ export default function AdminLayout({
</Providers>
);
}

function AdminLink({
href,
icon: Icon,
children,
}: {
href: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100",
)}
>
<Icon className="h-4 w-4" />
{children}
</Link>
);
}
15 changes: 7 additions & 8 deletions frontend/app/admin/payments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,12 @@ import {
StatusBadge,
Button,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

export default function AdminPaymentDetailPage() {
const token = useAdminToken();
return (
<RequireAdmin>
<PaymentDetailInner token={token as string} />
<PaymentDetailInner />
</RequireAdmin>
);
}
Expand Down Expand Up @@ -107,7 +106,7 @@ function ConfirmDialog({

type Action = "reconcile" | "resolve" | "void" | null;

function PaymentDetailInner({ token }: { token: string }) {
function PaymentDetailInner() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
Expand All @@ -119,7 +118,7 @@ function PaymentDetailInner({ token }: { token: string }) {
error,
} = useQuery({
queryKey: ["payment", id],
queryFn: () => getPayment(id, token),
queryFn: () => getPayment(id),
});

const invalidate = () => {
Expand All @@ -128,7 +127,7 @@ function PaymentDetailInner({ token }: { token: string }) {
};

const reconcile = useMutation({
mutationFn: () => forceReconcilePayment(token, id),
mutationFn: () => forceReconcilePayment(id),
onSuccess: () => {
invalidate();
toast.success("Payment force-reconciled.");
Expand All @@ -138,7 +137,7 @@ function PaymentDetailInner({ token }: { token: string }) {

const resolve = useMutation({
mutationFn: (resolution: string) =>
resolvePaymentManually(token, id, resolution),
resolvePaymentManually(id, resolution),
onSuccess: () => {
invalidate();
toast.success("Payment resolved manually.");
Expand All @@ -147,7 +146,7 @@ function PaymentDetailInner({ token }: { token: string }) {
});

const voidMut = useMutation({
mutationFn: (reason: string) => voidPayment(token, id, reason),
mutationFn: (reason: string) => voidPayment(id, reason),
onSuccess: () => {
invalidate();
toast.success("Payment voided.");
Expand Down
16 changes: 7 additions & 9 deletions frontend/app/admin/settlements/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,44 +21,42 @@ import {
Field,
inputClass,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

export default function BatchDetailPage() {
const token = useAdminToken();

return (
<RequireAdmin>
<BatchDetailInner token={token as string} />
<BatchDetailInner />
</RequireAdmin>
);
}

function BatchDetailInner({ token }: { token: string }) {
function BatchDetailInner() {
const params = useParams<{ id: string }>();
const id = params.id;
const queryClient = useQueryClient();
const [abandonReason, setAbandonReason] = useState("");

const detail = useQuery({
queryKey: ["settlement-batch", id],
queryFn: () => getBatchBreakdown(token, id),
queryFn: () => getBatchBreakdown(id),
});

const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["settlement-batch", id] });

const execute = useMutation({
mutationFn: () => executeBatch(token, id),
mutationFn: () => executeBatch(id),
onSuccess: invalidate,
onError: (err) => toast.error(err.message),
});
const retry = useMutation({
mutationFn: () => retryBatch(token, id),
mutationFn: () => retryBatch(id),
onSuccess: invalidate,
onError: (err) => toast.error(err.message),
});
const abandon = useMutation({
mutationFn: () => abandonBatch(token, id, abandonReason),
mutationFn: () => abandonBatch(id, abandonReason),
onSuccess: () => {
toast.success("Batch abandoned");
invalidate();
Expand Down
14 changes: 6 additions & 8 deletions frontend/app/admin/splits/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,39 +19,37 @@ import {
Button,
inputClass,
} from "@/components/admin/ui";
import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin";
import { RequireAdmin } from "@/components/admin/require-admin";

export default function SplitDetailPage() {
const token = useAdminToken();

return (
<RequireAdmin>
<SplitDetailInner token={token as string} />
<SplitDetailInner />
</RequireAdmin>
);
}

function SplitDetailInner({ token }: { token: string }) {
function SplitDetailInner() {
const params = useParams<{ id: string }>();
const id = params.id;
const queryClient = useQueryClient();

const configs = useQuery({
queryKey: ["revenue-splits"],
queryFn: () => listSplits(token),
queryFn: () => listSplits(),
});
const split = configs.data?.find((c) => c.id === id);

const [amount, setAmount] = useState("10000");

const preview = useQuery({
queryKey: ["split-preview", id, amount],
queryFn: () => previewSplit(token, id, Number(amount) || 0),
queryFn: () => previewSplit(id, Number(amount) || 0),
enabled: !!split && Number(amount) > 0,
});

const toggleActive = useMutation({
mutationFn: () => setSplitActive(token, id, !split?.active),
mutationFn: () => setSplitActive(id, !split?.active),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["revenue-splits"] });
toast.success(`Config ${split?.active ? "deactivated" : "activated"}`);
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/payments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function PaymentDetailPage() {
error,
} = useQuery({
queryKey: ["payments", "detail", id],
queryFn: () => getPayment(id, accessToken as string),
queryFn: () => getPayment(id),
enabled: Boolean(accessToken && id),
});

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/payments/return/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export default function PaymentReturnPage() {
if (result.verified || terminalStatus(result.payment.status)) {
setPhase("success");
} else {
// Not yet in a terminal state � fall through to polling
// Not yet in a terminal state, fall through to polling
startPolling(paymentId);
}
})
Expand Down
99 changes: 99 additions & 0 deletions frontend/components/admin/__tests__/admin-nav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePathname } from "next/navigation";
import { AdminNav, ADMIN_NAV, ADMIN_NAV_EXEMPT_ROUTES } from "../admin-nav";

vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
}));

const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const ADMIN_PAGES_DIR = path.resolve(TEST_DIR, "../../../app/admin");

function collectAdminPageRoutes(): string[] {
const routes: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
} else if (entry.name === "page.tsx") {
const relative = path.relative(ADMIN_PAGES_DIR, entryPath);
const route = path.dirname(relative)
.split(path.sep)
.filter((segment) => segment && segment !== ".")
.join("/");
routes.push(`admin${route ? `/${route}` : ""}`);
}
}
};
walk(ADMIN_PAGES_DIR);
return routes;
}

describe("admin nav", () => {
describe("route completeness", () => {
it("has an entry (or an explicit exemption) for every admin page", () => {
const navHrefs = ADMIN_NAV.map((item) => item.href);
const missing: string[] = [];

for (const route of collectAdminPageRoutes()) {
const isNavRoute = navHrefs.includes(`/${route}`);
const isExempt = ADMIN_NAV_EXEMPT_ROUTES.includes(route);
if (!isNavRoute && !isExempt) {
missing.push(route);
}
}

expect(missing).toEqual([]);
});

it("does not repeat hrefs across nav entries", () => {
const navHrefs = ADMIN_NAV.map((item) => item.href);
expect(new Set(navHrefs).size).toBe(navHrefs.length);
});
});

describe("active marking", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("marks only the current route link with aria-current and the active color", () => {
vi.mocked(usePathname).mockReturnValue("/admin/payments");

render(<AdminNav />);

const paymentsLink = screen.getByRole("link", { name: "Payments" });
expect(paymentsLink).toHaveAttribute("aria-current", "page");
expect(paymentsLink).toHaveClass("text-blue-600");

const dashboardLink = screen.getByRole("link", { name: "Dashboard" });
expect(dashboardLink).not.toHaveAttribute("aria-current");

const links = screen.getAllByRole("link");
const marked = links.filter(
(link) => link.getAttribute("aria-current") === "page",
);
expect(marked).toHaveLength(1);
expect(marked[0]).toBe(paymentsLink);
});

it("keeps the dashboard link unmarked on nested routes", () => {
vi.mocked(usePathname).mockReturnValue("/admin/settlements/batch-1");

render(<AdminNav />);

const dashboardLink = screen.getByRole("link", { name: "Dashboard" });
expect(dashboardLink).not.toHaveAttribute("aria-current");

const settlementsLink = screen.getByRole("link", {
name: "Settlement",
});
expect(settlementsLink).toHaveAttribute("aria-current", "page");
});
});
});
Loading
Loading