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}
/>
)}