From 4922f834721408f133cedc80966a174932771dce Mon Sep 17 00:00:00 2001 From: devrabiuhussain Date: Sat, 17 Oct 2026 21:47:46 +0100 Subject: [PATCH] Shared HTTP layer, admin nav and component placement cleanup (devrabiuhussain) - Add lib/http.ts exposing one typed request(): shared base URL, optional bearer auth plus cookie credentials, JSON handling, typed ApiError with status/code/message, one Sentry reporting hook, and the payments retry semantics; migrate auth/admin/payments/wallet/credits modules onto it and drop token threading from every caller (Closes #1976) - Add missing admin nav entries (Payments, Charge credits, Refunds) with an active-route aria-current marker and a guard test that fails when a new app/admin page lacks a nav entry or explicit exemption (Closes #1978) - Delete unused lib/WalletStatusCard.tsx and lib/WalletFundingModal.tsx, move AdminCreditsManagement under components/admin/, move esrow.md to docs/escrow.md, and add filename lint rules keeping React components out of lib/ with kebab-case component filenames (Closes #1981) - Delete the dead lib/i18n module (Closes #1982) Notes for reviewers: - Existing api tests pass unchanged except assertions on exact fetch credentials where the shared helper now defaults to credentials: "include" so the backend's HttpOnly session cookie is sent cross-origin. - Module signatures keep an optional backward-compatible accessToken parameter (tests pass tokens directly); the cookie-session refactor PR (#1975 by ykargeee-bit) removes the parameter and bearer headers entirely, so expect small textual conflicts in the five api modules and their tests when both land. --- frontend/esrow.md => docs/escrow.md | 0 frontend/README.md | 5 +- frontend/app/admin/accounts/page.tsx | 18 +- frontend/app/admin/charge/page.tsx | 2 +- frontend/app/admin/credits/page.tsx | 2 +- frontend/app/admin/integrity/page.tsx | 10 +- frontend/app/admin/layout.tsx | 50 +--- frontend/app/admin/payments/[id]/page.tsx | 15 +- frontend/app/admin/payments/page.tsx | 9 +- frontend/app/admin/refund/page.tsx | 4 +- frontend/app/admin/settlements/[id]/page.tsx | 16 +- frontend/app/admin/settlements/page.tsx | 12 +- frontend/app/admin/splits/[id]/page.tsx | 14 +- frontend/app/admin/splits/page.tsx | 16 +- frontend/app/credits/page.tsx | 8 +- frontend/app/payments/[id]/page.tsx | 2 +- frontend/app/payments/new/page.tsx | 2 +- frontend/app/payments/page.tsx | 2 +- frontend/app/payments/return/page.tsx | 14 +- frontend/app/usage/page.tsx | 2 +- .../admin/__tests__/admin-nav.test.tsx | 99 +++++++ frontend/components/admin/admin-nav.tsx | 74 +++++ .../admin/credits-management.tsx} | 2 +- frontend/eslint.config.mjs | 79 ++++++ frontend/lib/WalletFundingModal.tsx | 208 -------------- frontend/lib/WalletStatusCard.tsx | 210 -------------- frontend/lib/__tests__/http.test.ts | 263 ++++++++++++++++++ frontend/lib/__tests__/wallet-api.test.ts | 4 +- frontend/lib/admin-api.ts | 120 ++++---- frontend/lib/auth-api.ts | 39 +-- frontend/lib/credits-api.ts | 36 +-- frontend/lib/http.ts | 197 +++++++++++++ frontend/lib/i18n/config.ts | 8 - frontend/lib/i18n/index.ts | 16 -- frontend/lib/i18n/messages/en.json | 14 - frontend/lib/payments-api.ts | 152 +++------- frontend/lib/providers.tsx | 1 + frontend/lib/query-provider.tsx | 1 + frontend/lib/wallet-api.ts | 49 +--- 39 files changed, 916 insertions(+), 859 deletions(-) rename frontend/esrow.md => docs/escrow.md (100%) create mode 100644 frontend/components/admin/__tests__/admin-nav.test.tsx create mode 100644 frontend/components/admin/admin-nav.tsx rename frontend/{lib/AdminCreditsManagement.tsx => components/admin/credits-management.tsx} (99%) delete mode 100644 frontend/lib/WalletFundingModal.tsx delete mode 100644 frontend/lib/WalletStatusCard.tsx create mode 100644 frontend/lib/__tests__/http.test.ts create mode 100644 frontend/lib/http.ts delete mode 100644 frontend/lib/i18n/config.ts delete mode 100644 frontend/lib/i18n/index.ts delete mode 100644 frontend/lib/i18n/messages/en.json diff --git a/frontend/esrow.md b/docs/escrow.md similarity index 100% rename from frontend/esrow.md rename to docs/escrow.md diff --git a/frontend/README.md b/frontend/README.md index 488c88ad..68878e61 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -24,9 +24,8 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the 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/.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 diff --git a/frontend/app/admin/accounts/page.tsx b/frontend/app/admin/accounts/page.tsx index 4d70b33c..7f349453 100644 --- a/frontend/app/admin/accounts/page.tsx +++ b/frontend/app/admin/accounts/page.tsx @@ -16,7 +16,7 @@ import { SecondaryButton, inputClass, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; const KINDS: LedgerAccountKind[] = [ "USER", @@ -28,27 +28,25 @@ const KINDS: LedgerAccountKind[] = [ ]; export default function AccountsPage() { - const token = useAdminToken(); - return ( - + ); } -function AccountsInner({ token }: { token: string }) { +function AccountsInner() { const queryClient = useQueryClient(); const [showCreate, setShowCreate] = useState(false); const accounts = useQuery({ queryKey: ["ledger-accounts"], - queryFn: () => listAccounts(token), + queryFn: () => listAccounts(), }); const freeze = useMutation({ mutationFn: ({ id, frozen }: { id: string; frozen: boolean }) => - updateAccount(token, id, { frozen }), + updateAccount(undefined, id, { frozen }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["ledger-accounts"] }); toast.success("Account policy updated"); @@ -74,7 +72,7 @@ function AccountsInner({ token }: { token: string }) { {showCreate && ( - setShowCreate(false)} /> + setShowCreate(false)} /> )} @@ -176,10 +174,8 @@ function AccountRow({ } function CreateAccountForm({ - token, onDone, }: { - token: string; onDone: () => void; }) { const queryClient = useQueryClient(); @@ -201,7 +197,7 @@ function CreateAccountForm({ const create = useMutation({ mutationFn: () => - createAccount(token, { + createAccount(undefined, { kind: form.kind, ownerId: form.ownerId || undefined, currency: form.currency, diff --git a/frontend/app/admin/charge/page.tsx b/frontend/app/admin/charge/page.tsx index 3c75b425..f1583cf5 100644 --- a/frontend/app/admin/charge/page.tsx +++ b/frontend/app/admin/charge/page.tsx @@ -32,7 +32,7 @@ export default function AdminChargePage() { setBusy(true); try { const res = await chargeCredits( - token, + undefined, { userId, amount: Number(amount), diff --git a/frontend/app/admin/credits/page.tsx b/frontend/app/admin/credits/page.tsx index a639e062..05d16be6 100644 --- a/frontend/app/admin/credits/page.tsx +++ b/frontend/app/admin/credits/page.tsx @@ -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: () => ( diff --git a/frontend/app/admin/integrity/page.tsx b/frontend/app/admin/integrity/page.tsx index 568f359c..6fc1f147 100644 --- a/frontend/app/admin/integrity/page.tsx +++ b/frontend/app/admin/integrity/page.tsx @@ -5,22 +5,20 @@ import { CheckCircle2, AlertTriangle } from "lucide-react"; import { checkIntegrity, type LedgerIntegrityReport } from "@/lib/admin-api"; import { Card, CardHeader, Button } from "@/components/admin/ui"; import { IntegrityResults } from "@/components/admin/integrity-results"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; export default function IntegrityPage() { - const token = useAdminToken(); - return ( - + ); } -function IntegrityInner({ token }: { token: string }) { +function IntegrityInner() { const report = useQuery({ queryKey: ["ledger-integrity"], - queryFn: () => checkIntegrity(token), + queryFn: () => checkIntegrity(), refetchOnWindowFocus: false, }); diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index dca57495..52a03706 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -1,30 +1,14 @@ 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 { AdminProviders } from "@/lib/providers"; import { AdminBreadcrumbs } from "@/components/admin/admin-breadcrumbs"; -import { cn } from "@/lib/utils"; +import { AdminNav } from "@/components/admin/admin-nav"; 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, }: { @@ -46,13 +30,7 @@ export default function AdminLayout({
@@ -63,25 +41,3 @@ export default function AdminLayout({ ); } - -function AdminLink({ - href, - icon: Icon, - children, -}: { - href: string; - icon: React.ComponentType<{ className?: string }>; - children: React.ReactNode; -}) { - return ( - - - {children} - - ); -} diff --git a/frontend/app/admin/payments/[id]/page.tsx b/frontend/app/admin/payments/[id]/page.tsx index 9c4a58e1..c2f965b7 100644 --- a/frontend/app/admin/payments/[id]/page.tsx +++ b/frontend/app/admin/payments/[id]/page.tsx @@ -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 ( - + ); } @@ -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(); @@ -119,7 +118,7 @@ function PaymentDetailInner({ token }: { token: string }) { error, } = useQuery({ queryKey: ["payment", id], - queryFn: () => getPayment(id, token), + queryFn: () => getPayment(id), }); const invalidate = () => { @@ -128,7 +127,7 @@ function PaymentDetailInner({ token }: { token: string }) { }; const reconcile = useMutation({ - mutationFn: () => forceReconcilePayment(token, id), + mutationFn: () => forceReconcilePayment(undefined, id), onSuccess: () => { invalidate(); toast.success("Payment force-reconciled."); @@ -138,7 +137,7 @@ function PaymentDetailInner({ token }: { token: string }) { const resolve = useMutation({ mutationFn: (resolution: string) => - resolvePaymentManually(token, id, resolution), + resolvePaymentManually(undefined, id, resolution), onSuccess: () => { invalidate(); toast.success("Payment resolved manually."); @@ -147,7 +146,7 @@ function PaymentDetailInner({ token }: { token: string }) { }); const voidMut = useMutation({ - mutationFn: (reason: string) => voidPayment(token, id, reason), + mutationFn: (reason: string) => voidPayment(undefined, id, reason), onSuccess: () => { invalidate(); toast.success("Payment voided."); diff --git a/frontend/app/admin/payments/page.tsx b/frontend/app/admin/payments/page.tsx index 2255a467..b7c4208f 100644 --- a/frontend/app/admin/payments/page.tsx +++ b/frontend/app/admin/payments/page.tsx @@ -12,24 +12,23 @@ import { Badge, StatusBadge, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; const PAGE_SIZE = 25; export default function AdminManualReviewPage() { - const token = useAdminToken(); return ( - + ); } -function ManualReviewInner({ token }: { token: string }) { +function ManualReviewInner() { const [offset, setOffset] = useState(0); const { data, isLoading, error } = useQuery({ queryKey: ["manual-review-payments", offset], - queryFn: () => listManualReviewPayments(token, PAGE_SIZE, offset), + queryFn: () => listManualReviewPayments(undefined, PAGE_SIZE, offset), refetchInterval: 30_000, }); diff --git a/frontend/app/admin/refund/page.tsx b/frontend/app/admin/refund/page.tsx index 39c985b2..f888d6a7 100644 --- a/frontend/app/admin/refund/page.tsx +++ b/frontend/app/admin/refund/page.tsx @@ -31,7 +31,7 @@ function PaymentRow({ payment }: { payment: Payment }) { if (!token) return; setBusy(true); try { - await requestRefund(token, payment.id, Number(amount), reason); + await requestRefund(undefined, payment.id, Number(amount), reason); toast.success("Refund request submitted"); setReason(""); await queryClient.invalidateQueries({ queryKey: ["manual-review"] }); @@ -94,7 +94,7 @@ export default function AdminRefundPage() { const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null; const { data, isLoading, error } = useQuery({ queryKey: ["manual-review"], - queryFn: () => listManualReviewPayments(token!), + queryFn: () => listManualReviewPayments(), enabled: !!token, }); diff --git a/frontend/app/admin/settlements/[id]/page.tsx b/frontend/app/admin/settlements/[id]/page.tsx index b4752440..3b360d16 100644 --- a/frontend/app/admin/settlements/[id]/page.tsx +++ b/frontend/app/admin/settlements/[id]/page.tsx @@ -21,19 +21,17 @@ 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 ( - + ); } -function BatchDetailInner({ token }: { token: string }) { +function BatchDetailInner() { const params = useParams<{ id: string }>(); const id = params.id; const queryClient = useQueryClient(); @@ -41,24 +39,24 @@ function BatchDetailInner({ token }: { token: string }) { const detail = useQuery({ queryKey: ["settlement-batch", id], - queryFn: () => getBatchBreakdown(token, id), + queryFn: () => getBatchBreakdown(undefined, id), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ["settlement-batch", id] }); const execute = useMutation({ - mutationFn: () => executeBatch(token, id), + mutationFn: () => executeBatch(undefined, id), onSuccess: invalidate, onError: (err) => toast.error(err.message), }); const retry = useMutation({ - mutationFn: () => retryBatch(token, id), + mutationFn: () => retryBatch(undefined, id), onSuccess: invalidate, onError: (err) => toast.error(err.message), }); const abandon = useMutation({ - mutationFn: () => abandonBatch(token, id, abandonReason), + mutationFn: () => abandonBatch(undefined, id, abandonReason), onSuccess: () => { toast.success("Batch abandoned"); invalidate(); diff --git a/frontend/app/admin/settlements/page.tsx b/frontend/app/admin/settlements/page.tsx index 2f7fa6c8..f84d8426 100644 --- a/frontend/app/admin/settlements/page.tsx +++ b/frontend/app/admin/settlements/page.tsx @@ -13,7 +13,7 @@ import { StatusBadge, Button, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; import { SettlementVolumeChart } from "@/components/admin/settlement-volume-chart"; const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [ @@ -27,16 +27,14 @@ const STATUSES: Array<{ value: SettlementBatchStatus | ""; label: string }> = [ ]; export default function SettlementsPage() { - const token = useAdminToken(); - return ( - + ); } -function SettlementsInner({ token }: { token: string }) { +function SettlementsInner() { const queryClient = useQueryClient(); const status = typeof window !== "undefined" @@ -46,11 +44,11 @@ function SettlementsInner({ token }: { token: string }) { const batches = useQuery({ queryKey: ["settlement-batches", status], queryFn: () => - listBatches(token, (status || undefined) as SettlementBatchStatus | undefined), + listBatches(undefined, (status || undefined) as SettlementBatchStatus | undefined), }); const run = useMutation({ - mutationFn: () => runSettlement(token), + mutationFn: () => runSettlement(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["settlement-batches"] }); toast.success("Settlement pass completed"); diff --git a/frontend/app/admin/splits/[id]/page.tsx b/frontend/app/admin/splits/[id]/page.tsx index be6c2bdc..e6d8df90 100644 --- a/frontend/app/admin/splits/[id]/page.tsx +++ b/frontend/app/admin/splits/[id]/page.tsx @@ -19,26 +19,24 @@ 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 ( - + ); } -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); @@ -46,12 +44,12 @@ function SplitDetailInner({ token }: { token: string }) { const preview = useQuery({ queryKey: ["split-preview", id, amount], - queryFn: () => previewSplit(token, id, Number(amount) || 0), + queryFn: () => previewSplit(undefined, id, Number(amount) || 0), enabled: !!split && Number(amount) > 0, }); const toggleActive = useMutation({ - mutationFn: () => setSplitActive(token, id, !split?.active), + mutationFn: () => setSplitActive(undefined, id, !split?.active), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["revenue-splits"] }); toast.success(`Config ${split?.active ? "deactivated" : "activated"}`); diff --git a/frontend/app/admin/splits/page.tsx b/frontend/app/admin/splits/page.tsx index 7962b732..49d8afda 100644 --- a/frontend/app/admin/splits/page.tsx +++ b/frontend/app/admin/splits/page.tsx @@ -16,24 +16,22 @@ import { SecondaryButton, inputClass, } from "@/components/admin/ui"; -import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +import { RequireAdmin } from "@/components/admin/require-admin"; export default function SplitsPage() { - const token = useAdminToken(); - return ( - + ); } -function SplitsInner({ token }: { token: string }) { +function SplitsInner() { const [showCreate, setShowCreate] = useState(false); const splits = useQuery({ queryKey: ["revenue-splits"], - queryFn: () => listSplits(token), + queryFn: () => listSplits(), }); return ( @@ -55,7 +53,7 @@ function SplitsInner({ token }: { token: string }) {
{showCreate && ( - setShowCreate(false)} /> + setShowCreate(false)} /> )} @@ -104,7 +102,7 @@ function SplitsInner({ token }: { token: string }) { ); } -function CreateSplitForm({ token, onDone }: { token: string; onDone: () => void }) { +function CreateSplitForm({ onDone }: { onDone: () => void }) { const queryClient = useQueryClient(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -130,7 +128,7 @@ function CreateSplitForm({ token, onDone }: { token: string; onDone: () => void const create = useMutation({ mutationFn: () => - createSplit(token, { + createSplit(undefined, { name, description: description || undefined, recipients: recipients.map((r) => ({ diff --git a/frontend/app/credits/page.tsx b/frontend/app/credits/page.tsx index 50066e8a..fda2a664 100644 --- a/frontend/app/credits/page.tsx +++ b/frontend/app/credits/page.tsx @@ -32,20 +32,20 @@ export default function CreditsPage() { ); } - return ; + return ; } -function CreditsInner({ token }: { token: string }) { +function CreditsInner() { const [page, setPage] = useState(1); const balance = useQuery({ queryKey: ["credit-balance"], - queryFn: () => getCreditBalance(token), + queryFn: () => getCreditBalance(), }); const statement = useQuery({ queryKey: ["credit-statement", page], - queryFn: () => getCreditStatement(token, page, PAGE_SIZE), + queryFn: () => getCreditStatement(undefined, page, PAGE_SIZE), }); const totalPages = statement.data diff --git a/frontend/app/payments/[id]/page.tsx b/frontend/app/payments/[id]/page.tsx index 588535ed..b3d778f0 100644 --- a/frontend/app/payments/[id]/page.tsx +++ b/frontend/app/payments/[id]/page.tsx @@ -37,7 +37,7 @@ export default function PaymentDetailPage() { error, } = useQuery({ queryKey: ["payments", "detail", id], - queryFn: () => getPayment(id, accessToken as string), + queryFn: () => getPayment(id), enabled: Boolean(accessToken && id), }); diff --git a/frontend/app/payments/new/page.tsx b/frontend/app/payments/new/page.tsx index c98d2536..801a7c45 100644 --- a/frontend/app/payments/new/page.tsx +++ b/frontend/app/payments/new/page.tsx @@ -25,7 +25,7 @@ export default function NewPaymentPage() { setSubmitting(true); try { const payment = await initiatePayment( - token, + undefined, { bookingId, amount: Number(amount), diff --git a/frontend/app/payments/page.tsx b/frontend/app/payments/page.tsx index ad9253f9..c9c61721 100644 --- a/frontend/app/payments/page.tsx +++ b/frontend/app/payments/page.tsx @@ -39,7 +39,7 @@ export default function PaymentsPage() { error, } = useQuery({ queryKey: ["payments", "list"], - queryFn: () => getPayments(accessToken as string), + queryFn: () => getPayments(), enabled: Boolean(accessToken), }); diff --git a/frontend/app/payments/return/page.tsx b/frontend/app/payments/return/page.tsx index e15d5f29..267aa4b3 100644 --- a/frontend/app/payments/return/page.tsx +++ b/frontend/app/payments/return/page.tsx @@ -121,7 +121,7 @@ export default function PaymentReturnPage() { // -- polling loop -------------------------------------------------------- const startPolling = useCallback( - (id: string, token: string) => { + (id: string) => { let attempt = 0; setPhase("polling"); @@ -129,7 +129,7 @@ export default function PaymentReturnPage() { attempt += 1; setPollCount(attempt); - getPayment(id, token) + getPayment(id) .then((p) => { setPayment(p); if (terminalStatus(p.status)) { @@ -164,10 +164,10 @@ export default function PaymentReturnPage() { // Arm the timeout that triggers the polling fallback const timeoutId = setTimeout(() => { ctrl.abort(); - startPolling(paymentId, accessToken as string); + startPolling(paymentId); }, FAST_PATH_TIMEOUT_MS); - verifyPaymentReturn(accessToken as string, paymentId) + verifyPaymentReturn(paymentId) .then((result) => { clearTimeout(timeoutId); if (ctrl.signal.aborted) return; // timeout already fired, ignore @@ -175,8 +175,8 @@ export default function PaymentReturnPage() { if (result.verified || terminalStatus(result.payment.status)) { setPhase("success"); } else { - // Not yet in a terminal state � fall through to polling - startPolling(paymentId, accessToken as string); + // Not yet in a terminal state, fall through to polling + startPolling(paymentId); } }) .catch((err: unknown) => { @@ -187,7 +187,7 @@ export default function PaymentReturnPage() { "[verify-return] fast path failed, falling back to polling:", err, ); - startPolling(paymentId, accessToken as string); + startPolling(paymentId); }); return () => { diff --git a/frontend/app/usage/page.tsx b/frontend/app/usage/page.tsx index 2de74440..0f5efe36 100644 --- a/frontend/app/usage/page.tsx +++ b/frontend/app/usage/page.tsx @@ -14,7 +14,7 @@ export default function UsagePage() { const token = typeof window !== "undefined" ? Cookies.get("accessToken") : null; const { data, isLoading, error } = useQuery({ queryKey: ["my-usage"], - queryFn: () => listMyUsage(token!), + queryFn: () => listMyUsage(), enabled: !!token, }); diff --git a/frontend/components/admin/__tests__/admin-nav.test.tsx b/frontend/components/admin/__tests__/admin-nav.test.tsx new file mode 100644 index 00000000..31fd630f --- /dev/null +++ b/frontend/components/admin/__tests__/admin-nav.test.tsx @@ -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(); + + 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(); + + 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"); + }); + }); +}); diff --git a/frontend/components/admin/admin-nav.tsx b/frontend/components/admin/admin-nav.tsx new file mode 100644 index 00000000..26dd17be --- /dev/null +++ b/frontend/components/admin/admin-nav.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Coins, + CreditCard, + Landmark, + LayoutDashboard, + RefreshCw, + RotateCcw, + Scale, + Split, + Zap, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface AdminNavItem { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; +} + +export const ADMIN_NAV: AdminNavItem[] = [ + { 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 }, + { href: "/admin/payments", label: "Payments", icon: CreditCard }, + { href: "/admin/charge", label: "Charge credits", icon: Zap }, + { href: "/admin/refund", label: "Refunds", icon: RotateCcw }, +]; + +// Detail pages reached from their parent list, not navigated directly. +export const ADMIN_NAV_EXEMPT_ROUTES: string[] = [ + "admin/payments/[id]", + "admin/settlements/[id]", + "admin/splits/[id]", +]; + +function isActive(pathname: string, href: string): boolean { + if (href === "/admin") { + return pathname === "/admin"; + } + return pathname === href || pathname.startsWith(`${href}/`); +} + +export function AdminNav() { + const pathname = usePathname() ?? ""; + + return ( + + ); +} diff --git a/frontend/lib/AdminCreditsManagement.tsx b/frontend/components/admin/credits-management.tsx similarity index 99% rename from frontend/lib/AdminCreditsManagement.tsx rename to frontend/components/admin/credits-management.tsx index facb75e2..51d4f6a1 100644 --- a/frontend/lib/AdminCreditsManagement.tsx +++ b/frontend/components/admin/credits-management.tsx @@ -1,4 +1,4 @@ -// src/components/AdminCreditsManagement.tsx +// components/admin/credits-management.tsx 'use client'; import React, { useState } from 'react'; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 4e8b45c8..af06ac4b 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -9,6 +9,73 @@ const compat = new FlatCompat({ baseDirectory: __dirname, }); +// Inline plugin keeping component files in the right places without adding +// any dependency: React components live under components/, and component +// filenames must be kebab-case. +const localFilenamesRules = { + "no-components-in-lib": { + meta: { + type: "problem", + docs: { + description: "Disallow React components directly inside a lib folder.", + }, + schema: [], + messages: { + componentInLib: "React components must live under components/, not lib/.", + }, + }, + create(context) { + return { + Program(node) { + const segments = String(context.filename).replace(/\\/g, "/").split("/"); + const basename = segments[segments.length - 1]; + if (!basename.endsWith(".tsx")) { + return; + } + const parentSegment = segments[segments.length - 2]; + // Only files directly inside a lib folder (not lib/pages etc.). + if (parentSegment === "lib") { + context.report({ + node, + messageId: "componentInLib", + }); + } + }, + }; + }, + }, + "component-filename-case": { + meta: { + type: "problem", + docs: { + description: "Require kebab-case filenames for component files.", + }, + schema: [], + messages: { + notKebabCase: "Component filenames must be kebab-case.", + }, + }, + create(context) { + return { + Program(node) { + const filename = String(context.filename).replace(/\\/g, "/"); + if (!filename.includes("/components/")) { + return; + } + const basename = filename.slice(filename.lastIndexOf("/") + 1); + const stem = basename.replace(/\.tsx$/i, ""); + if (/[A-Z]/.test(stem)) { + context.report({ + node, + messageId: "notKebabCase", + }); + } + }, + }; + }, + }, +}; + const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), { @@ -27,6 +94,18 @@ const eslintConfig = [ "@typescript-eslint/no-empty-object-type": "off", }, }, + { + files: ["**/*.tsx"], + plugins: { + "local-filenames": { + rules: localFilenamesRules, + }, + }, + rules: { + "local-filenames/no-components-in-lib": "error", + "local-filenames/component-filename-case": "error", + }, + }, ]; export default eslintConfig; diff --git a/frontend/lib/WalletFundingModal.tsx b/frontend/lib/WalletFundingModal.tsx deleted file mode 100644 index 0ba2a3ac..00000000 --- a/frontend/lib/WalletFundingModal.tsx +++ /dev/null @@ -1,208 +0,0 @@ -// src/components/WalletFundingModal.tsx -'use client'; - -import React, { useEffect, useRef } from 'react'; -import { DollarSign, Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; - -interface WalletFundingModalProps { - userId: string; - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -} - -const FOCUSABLE = - 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; - -export const WalletFundingModal: React.FC = ({ - userId, - isOpen, - onClose, - onSuccess, -}) => { - const [amount, setAmount] = React.useState(''); - const [paymentMethod, setPaymentMethod] = React.useState('card'); - const [loading, setLoading] = React.useState(false); - const dialogRef = useRef(null); - - useEffect(() => { - if (!isOpen) return; - - const dialog = dialogRef.current; - const previouslyFocused = document.activeElement as HTMLElement | null; - const focusTargets = () => - Array.from( - dialog?.querySelectorAll(FOCUSABLE) ?? [], - ).filter((el) => el.offsetParent !== null); - - // Move focus into the dialog so keyboard/screen-reader users start - // inside the modal instead of behind the overlay (issue #1815). - const firstTarget = focusTargets()[0]; - firstTarget?.focus(); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - return; - } - if (event.key !== 'Tab' || !dialog) return; - - const targets = focusTargets(); - if (targets.length === 0) { - event.preventDefault(); - return; - } - - const first = targets[0]; - const last = targets[targets.length - 1]; - const active = document.activeElement as HTMLElement | null; - - // Trap Tab / Shift+Tab inside the dialog: wrap to the last element - // when tabbing past the end, and to the first when shifting past - // the start (or when focus somehow escapes the dialog entirely). - if (event.shiftKey) { - if (active === first || !dialog.contains(active)) { - event.preventDefault(); - last.focus(); - } - } else if (active === last || !dialog.contains(active)) { - event.preventDefault(); - first.focus(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - - // Restore focus to the trigger element when the modal closes. - return () => { - document.removeEventListener('keydown', handleKeyDown); - previouslyFocused?.focus(); - }; - }, [isOpen, onClose]); - - if (!isOpen) return null; - - const handleFundWallet = async (e: React.FormEvent) => { - e.preventDefault(); - const parsedAmount = parseFloat(amount); - - if (isNaN(parsedAmount) || parsedAmount <= 0) { - toast.error('Please enter a valid funding amount greater than zero.'); - return; - } - - setLoading(true); - try { - const response = await fetch(`/wallets/${userId}/fund`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ amount: parsedAmount, paymentMethod }), - }); - - if (!response.ok) { - throw new Error('Failed to process wallet funding request.'); - } - - toast.success(`Successfully added $${parsedAmount.toFixed(2)} to wallet.`); - onSuccess(); - onClose(); - } catch (err: any) { - toast.error(err.message || 'An error occurred while funding the wallet.'); - } finally { - setLoading(false); - } - }; - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
-
-

- - Fund Wallet -

- -
- -
-
- -
-
- $ -
- setAmount(e.target.value)} - className="block w-full pl-7 pr-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500 text-sm" - required - /> -
-
- -
- - -
- -
- - -
-
-
-
- ); -}; \ No newline at end of file diff --git a/frontend/lib/WalletStatusCard.tsx b/frontend/lib/WalletStatusCard.tsx deleted file mode 100644 index e87aea3b..00000000 --- a/frontend/lib/WalletStatusCard.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// src/components/WalletStatusCard.tsx -'use client'; - -import React, { useState } from 'react'; -import { Wallet, ShieldCheck, AlertCircle, Loader2, ExternalLink } from 'lucide-react'; -import { useToast } from './ToastProvider'; - -interface WalletStatusCardProps { - userId: string; - isLinked: boolean; - walletAddress?: string; - onLinkSuccess: () => void; -} - -export const WalletStatusCard: React.FC = ({ - userId, - isLinked, - walletAddress, - onLinkSuccess, -}) => { - const [loading, setLoading] = useState(false); - const [showManualFallback, setShowManualFallback] = useState(false); - const [manualSignature, setManualSignature] = useState(''); - const { toast } = useToast(); - - const handleFreighterLink = async () => { - setLoading(true); - try { - // Check if Freighter extension is installed - const freighter = (window as any).freighter; - if (!freighter || typeof freighter.isConnected !== 'function') { - throw new Error('Freighter wallet extension not detected. Please install Freighter or use manual linking.'); - } - - const isConnected = await freighter.isConnected(); - if (!isConnected) { - throw new Error('Freighter wallet is not connected or permission was denied.'); - } - - const publicKey = await freighter.getPublicKey(); - if (!publicKey) { - throw new Error('Failed to retrieve public key from Freighter wallet.'); - } - - // Fetch challenge nonce from backend - const challengeRes = await fetch(`/wallets/${userId}/challenge`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicKey }), - }); - - if (!challengeRes.ok) { - throw new Error('Failed to fetch authentication challenge nonce.'); - } - - const { nonce } = await challengeRes.json(); - - // Sign challenge using Freighter - const signature = await freighter.signMessage(nonce); - - // Submit verification and link wallet - const linkRes = await fetch(`/wallets/${userId}/link`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicKey, signature }), - }); - - if (!linkRes.ok) { - throw new Error('Failed to verify wallet signature and link account.'); - } - - toast('Wallet Linked', 'Successfully linked your Stellar wallet via Freighter.', 'success'); - onLinkSuccess(); - } catch (err: any) { - toast('Linking Failed', err.message || 'An unexpected error occurred during wallet linking.', 'error'); - } finally { - setLoading(false); - } - }; - - const handleManualLink = async (e: React.FormEvent) => { - e.preventDefault(); - if (!manualSignature.trim()) { - toast('Invalid Input', 'Please enter a valid signature string.', 'error'); - return; - } - - setLoading(true); - try { - const linkRes = await fetch(`/wallets/${userId}/link`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ signature: manualSignature.trim() }), - }); - - if (!linkRes.ok) { - throw new Error('Manual signature verification failed.'); - } - - toast('Wallet Linked', 'Successfully linked wallet via manual signature.', 'success'); - onLinkSuccess(); - } catch (err: any) { - toast('Linking Failed', err.message || 'An error occurred during manual linking.', 'error'); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

- - Stellar Wallet Status -

- {isLinked ? ( - - - Linked - - ) : ( - - - Unlinked - - )} -
- - {isLinked ? ( -
-

Your account is successfully paired with address:

- - {walletAddress || 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'} - -
- ) : ( -
-

- Link your Stellar wallet extension to verify your identity and manage payouts automatically. -

- - {!showManualFallback ? ( -
- -
- - Install Freighter Extension - - -
-
- ) : ( -
-
- -