diff --git a/contracts/contracts/social_payment/src/lib.rs b/contracts/contracts/social_payment/src/lib.rs index ffc1cbf..384d2a0 100644 --- a/contracts/contracts/social_payment/src/lib.rs +++ b/contracts/contracts/social_payment/src/lib.rs @@ -571,7 +571,10 @@ impl SocialPaymentContract { return Err(Error::Unauthorized); } - // #752 / #531 — enforce max batch size; return typed error on violation + // #991 / #752 / #531 — enforce max recipient count before the payout loop. + // Limits the `payouts` vector to MAX_BATCH_SIZE (100) elements per call, + // preventing CPU / gas exhaustion from unbounded iteration in Soroban. + // Returns the typed Error::BatchTooLarge so callers can inspect the failure. let batch_size = payouts.len(); if batch_size > MAX_BATCH_SIZE { return Err(Error::BatchTooLarge); diff --git a/dashboard/app/dashboard/contracts/page.tsx b/dashboard/app/dashboard/contracts/page.tsx index d89d578..9d0f27d 100644 --- a/dashboard/app/dashboard/contracts/page.tsx +++ b/dashboard/app/dashboard/contracts/page.tsx @@ -335,7 +335,15 @@ function UsernamesTable() { - {data &&

{data.length} active registrations

} + {/* #1002 — active registration count badge */} + {data && ( +

+ {data.length} active registration{data.length !== 1 ? "s" : ""} +

+ )} {/* Blacklist Confirmation Drawer */} {showBlacklistDrawer && ( diff --git a/dashboard/app/dashboard/page.tsx b/dashboard/app/dashboard/page.tsx index 466005e..5d3fb40 100644 --- a/dashboard/app/dashboard/page.tsx +++ b/dashboard/app/dashboard/page.tsx @@ -1,12 +1,140 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { format } from "date-fns"; import StatCard from "@/components/StatCard"; -import { api, type AdminAuditLog } from "@/lib/api"; +import { api, type AdminAuditLog, type UserSearchResult } from "@/lib/api"; import { usePolling } from "@/lib/use-polling"; import { useSuperAdmin } from "@/lib/auth-context"; +// ── #1003: Username search widget ────────────────────────────────────────────── +/** + * Autocomplete search bar for looking up registered usernames. + * Debounces calls to /api/users/search, shows a dropdown of matches, + * and redirects to /dashboard/transactions?user= on selection. + */ +function UsernameSearchBar() { + const router = useRouter(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const timerRef = useRef | null>(null); + + const debouncedSearch = useCallback((value: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + if (value.trim().length < 2) { + setResults([]); + setOpen(false); + return; + } + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const data = await api.searchUsers(value.trim()); + setResults(data); + setOpen(data.length > 0); + } catch { + setResults([]); + setOpen(false); + } finally { + setLoading(false); + } + }, 300); + }, []); + + const handleSelect = (username: string) => { + setQuery(""); + setOpen(false); + router.push( + `/dashboard/transactions?user=${encodeURIComponent(username)}`, + ); + }; + + return ( +
+
+ + { + const value = e.target.value; + setQuery(value); + debouncedSearch(value); + }} + onFocus={() => results.length > 0 && setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 200)} + placeholder="Search username…" + autoComplete="off" + aria-label="Search users by username" + aria-expanded={open} + aria-haspopup="listbox" + role="combobox" + className="w-full rounded-lg border border-slate-300 bg-white pl-9 pr-9 py-2 text-sm + focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {loading && ( + + )} +
+ + {open && ( +
    + {results.map((u) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + function fmtUsdc(value: number): string { return ( value.toLocaleString(undefined, { @@ -107,17 +235,21 @@ export default function OverviewPage() { Live engagement across recent payment feeds.

- +
+ {/* #1003 — username lookup with autocomplete */} + + +
{/* Social Overview */} diff --git a/dashboard/app/dashboard/transactions/page.tsx b/dashboard/app/dashboard/transactions/page.tsx index cf87196..4e259ba 100644 --- a/dashboard/app/dashboard/transactions/page.tsx +++ b/dashboard/app/dashboard/transactions/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { format } from "date-fns"; import { api } from "@/lib/api"; import { usePolling } from "@/lib/use-polling"; @@ -110,8 +110,13 @@ export default function TransactionsPage() { } function TransactionsPageInner() { + const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const headerQuery = searchParams.get("q") ?? ""; + // #1005 — auto-open the payment detail dialog when a `?user=` param is present + // (set by the global SearchBar autocomplete on username selection). + const userParam = searchParams.get("user") ?? ""; const [search, setSearch] = useState(""); const [page, setPage] = useState(0); // #795 — items-per-page state @@ -127,6 +132,22 @@ function TransactionsPageInner() { setSelectedUser(username); }, []); + // #1005 — open the dialog automatically when arriving via ?user= query param + useEffect(() => { + if (userParam) { + setSelectedUser(userParam); + } + }, [userParam]); + + // #1005 — clear the ?user= param from the URL when the dialog is closed + const handleDialogClose = useCallback(() => { + setSelectedUser(null); + const params = new URLSearchParams(searchParams.toString()); + params.delete("user"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [pathname, router, searchParams]); + const activeQuery = search || headerQuery; useEffect(() => { @@ -372,7 +393,7 @@ function TransactionsPageInner() { {selectedUser && ( setSelectedUser(null)} + onClose={handleDialogClose} /> )}