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 2c32eec4..23525b13 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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/.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/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/layout.tsx b/frontend/app/admin/layout.tsx index ddcad373..54c6c695 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -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, }: { @@ -50,13 +34,7 @@ export default function AdminLayout({
@@ -67,25 +45,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..29fa4a3c 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(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(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(id, reason), onSuccess: () => { invalidate(); toast.success("Payment voided."); diff --git a/frontend/app/admin/settlements/[id]/page.tsx b/frontend/app/admin/settlements/[id]/page.tsx index b4752440..336fd7fb 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(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(); diff --git a/frontend/app/admin/splits/[id]/page.tsx b/frontend/app/admin/splits/[id]/page.tsx index be6c2bdc..d1faa1d5 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(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"}`); diff --git a/frontend/app/payments/[id]/page.tsx b/frontend/app/payments/[id]/page.tsx index dcc7755a..0e0e97c0 100644 --- a/frontend/app/payments/[id]/page.tsx +++ b/frontend/app/payments/[id]/page.tsx @@ -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), }); diff --git a/frontend/app/payments/return/page.tsx b/frontend/app/payments/return/page.tsx index cdf4010a..f2d8d0bc 100644 --- a/frontend/app/payments/return/page.tsx +++ b/frontend/app/payments/return/page.tsx @@ -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); } }) 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/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 - - -
-
- ) : ( -
-
- -