From 1375697486355a9cff522308c444d3ba2054ea9e Mon Sep 17 00:00:00 2001
From: neumattock <152253273+newmattock@users.noreply.github.com>
Date: Tue, 26 May 2026 09:47:00 -0700
Subject: [PATCH] feat: add payer reputation marketplace filter
---
__tests__/invoiceFilters.test.ts | 35 +++++++++++++++++++++++------
src/components/InvoiceFilterBar.tsx | 34 ++++++++++++++++++++++++++++
src/components/LPDashboard.tsx | 9 +++++++-
src/hooks/useInvoiceFilters.ts | 34 ++++++++++++++++++++++++++--
vitest.config.ts | 2 +-
5 files changed, 103 insertions(+), 11 deletions(-)
diff --git a/__tests__/invoiceFilters.test.ts b/__tests__/invoiceFilters.test.ts
index 6174a1f..ae46235 100644
--- a/__tests__/invoiceFilters.test.ts
+++ b/__tests__/invoiceFilters.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { applyInvoiceFilters, countActiveInvoiceFilters, EMPTY_INVOICE_FILTERS } from "../hooks/useInvoiceFilters";
-import type { Invoice } from "../utils/soroban";
+import { applyInvoiceFilters, countActiveInvoiceFilters, EMPTY_INVOICE_FILTERS } from "../src/hooks/useInvoiceFilters";
+import type { Invoice } from "../src/utils/soroban";
function makeInvoice(
id: bigint,
@@ -9,11 +9,12 @@ function makeInvoice(
dueDate: bigint,
discountRate: number,
token?: string,
+ payer = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBRY",
): Invoice {
return {
id,
freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
- payer: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBRY",
+ payer,
amount,
due_date: dueDate,
discount_rate: discountRate,
@@ -26,9 +27,9 @@ function makeInvoice(
describe("invoice filter logic", () => {
const invoices: Invoice[] = [
- makeInvoice(101n, "Pending", 100n * 10_000_000n, 1_760_000_000n, 300, "token-usdc"),
- makeInvoice(202n, "Funded", 500n * 10_000_000n, 1_770_000_000n, 900, "token-eurc"),
- makeInvoice(303n, "Paid", 900n * 10_000_000n, 1_780_000_000n, 1200, "token-xlm"),
+ makeInvoice(101n, "Pending", 100n * 10_000_000n, 1_760_000_000n, 300, "token-usdc", "PAYER_LOW"),
+ makeInvoice(202n, "Funded", 500n * 10_000_000n, 1_770_000_000n, 900, "token-eurc", "PAYER_HIGH"),
+ makeInvoice(303n, "Paid", 900n * 10_000_000n, 1_780_000_000n, 1200, "token-xlm", "PAYER_UNKNOWN"),
];
it("searches by id and address fragments", () => {
@@ -68,6 +69,25 @@ describe("invoice filter logic", () => {
expect(filtered.map((invoice) => invoice.id)).toEqual([202n]);
});
+ it("filters by minimum payer reputation score", () => {
+ const filtered = applyInvoiceFilters(
+ invoices,
+ {
+ ...EMPTY_INVOICE_FILTERS,
+ minPayerReputation: "75",
+ },
+ {
+ resolvePayerReputation: (invoice) => {
+ if (invoice.payer === "PAYER_LOW") return 40;
+ if (invoice.payer === "PAYER_HIGH") return 90;
+ return null;
+ },
+ },
+ );
+
+ expect(filtered.map((invoice) => invoice.id)).toEqual([202n]);
+ });
+
it("counts active filter groups correctly", () => {
expect(
countActiveInvoiceFilters({
@@ -76,7 +96,8 @@ describe("invoice filter logic", () => {
statuses: ["Pending"],
minAmount: "10",
token: "USDC",
+ minPayerReputation: "80",
}),
- ).toBe(4);
+ ).toBe(5);
});
});
diff --git a/src/components/InvoiceFilterBar.tsx b/src/components/InvoiceFilterBar.tsx
index 217c51d..6a94a89 100644
--- a/src/components/InvoiceFilterBar.tsx
+++ b/src/components/InvoiceFilterBar.tsx
@@ -13,6 +13,7 @@ type InvoiceFilterBarProps = {
onClearFilters: () => void;
activeFilterCount: number;
className?: string;
+ showReputationFilter?: boolean;
};
const TOKEN_OPTIONS = ["USDC", "EURC", "XLM"] as const;
@@ -23,6 +24,7 @@ export default function InvoiceFilterBar({
onClearFilters,
activeFilterCount,
className,
+ showReputationFilter = false,
}: InvoiceFilterBarProps) {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
@@ -190,6 +192,38 @@ export default function InvoiceFilterBar({
/>
+
+ {showReputationFilter ? (
+
+
+
+ Min Payer Reputation
+
+
+ {filters.minPayerReputation || "0"}
+
+
+
+ onFiltersChange((current) => ({
+ ...current,
+ minPayerReputation: event.target.value === "0" ? "" : event.target.value,
+ }))
+ }
+ className="w-full accent-primary"
+ aria-label="Min Payer Reputation"
+ />
+
+ Show all
+ 100
+
+
+ ) : null}
) : null}
diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx
index 881a8af..57e4696 100644
--- a/src/components/LPDashboard.tsx
+++ b/src/components/LPDashboard.tsx
@@ -174,8 +174,9 @@ export default function LPDashboard() {
const token = tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "");
return token?.symbol ?? "USDC";
},
+ resolvePayerReputation: (invoice) => payerScores.get(invoice.payer)?.score ?? null,
}),
- [defaultToken?.contractId, filters, invoices, tokenMap],
+ [defaultToken?.contractId, filters, invoices, payerScores, tokenMap],
);
@@ -481,6 +482,7 @@ export default function LPDashboard() {
onFiltersChange={setFilters}
onClearFilters={clearFilters}
activeFilterCount={activeFilterCount}
+ showReputationFilter
/>
@@ -595,6 +597,11 @@ export default function LPDashboard() {
risk={payerRisks.get(invoice.payer) ?? "Unknown"}
score={payerScores.get(invoice.payer) ?? null}
/>
+ {payerScores.get(invoice.payer) ? (
+
+ Score {payerScores.get(invoice.payer)?.score}/100
+
+ ) : null}
)}
diff --git a/src/hooks/useInvoiceFilters.ts b/src/hooks/useInvoiceFilters.ts
index 8961072..ddddd51 100644
--- a/src/hooks/useInvoiceFilters.ts
+++ b/src/hooks/useInvoiceFilters.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { Invoice } from "@/utils/soroban";
@@ -17,6 +17,7 @@ export type InvoiceFilters = {
token: string;
minDiscountBps: string;
maxDiscountBps: string;
+ minPayerReputation: string;
};
export const EMPTY_INVOICE_FILTERS: InvoiceFilters = {
@@ -29,6 +30,7 @@ export const EMPTY_INVOICE_FILTERS: InvoiceFilters = {
token: "",
minDiscountBps: "",
maxDiscountBps: "",
+ minPayerReputation: "",
};
type UseInvoiceFiltersOptions = {
@@ -78,12 +80,18 @@ function buildFilterQuery(searchParams: URLSearchParams, namespace: string, filt
setOrDelete("token", filters.token);
setOrDelete("minDiscountBps", filters.minDiscountBps);
setOrDelete("maxDiscountBps", filters.maxDiscountBps);
+ setOrDelete("minPayerReputation", filters.minPayerReputation);
return next;
}
function readFiltersFromParams(searchParams: URLSearchParams, namespace: string): InvoiceFilters {
const prefix = `${namespace}_`;
+ const persistedReputation =
+ typeof window === "undefined"
+ ? ""
+ : window.localStorage.getItem(`${prefix}minPayerReputation`) ?? "";
+
return {
search: searchParams.get(`${prefix}search`) ?? "",
statuses: cleanStatusList((searchParams.get(`${prefix}statuses`) ?? "").split(",").filter(Boolean)),
@@ -94,6 +102,7 @@ function readFiltersFromParams(searchParams: URLSearchParams, namespace: string)
token: searchParams.get(`${prefix}token`) ?? "",
minDiscountBps: searchParams.get(`${prefix}minDiscountBps`) ?? "",
maxDiscountBps: searchParams.get(`${prefix}maxDiscountBps`) ?? "",
+ minPayerReputation: searchParams.get(`${prefix}minPayerReputation`) ?? persistedReputation,
};
}
@@ -105,13 +114,17 @@ export function countActiveInvoiceFilters(filters: InvoiceFilters): number {
Boolean(filters.startDate.trim() || filters.endDate.trim()),
Boolean(filters.token.trim()),
Boolean(filters.minDiscountBps.trim() || filters.maxDiscountBps.trim()),
+ Boolean(filters.minPayerReputation.trim() && filters.minPayerReputation !== "0"),
].filter(Boolean).length;
}
export function applyInvoiceFilters(
invoices: Invoice[],
filters: InvoiceFilters,
- options?: { resolveTokenSymbol?: (invoice: Invoice) => string },
+ options?: {
+ resolveTokenSymbol?: (invoice: Invoice) => string;
+ resolvePayerReputation?: (invoice: Invoice) => number | null | undefined;
+ },
): Invoice[] {
const search = filters.search.trim().toLowerCase();
const minAmount = parseNumeric(filters.minAmount);
@@ -122,6 +135,7 @@ export function applyInvoiceFilters(
const start = filters.startDate ? new Date(`${filters.startDate}T00:00:00.000Z`) : null;
const end = filters.endDate ? new Date(`${filters.endDate}T23:59:59.999Z`) : null;
const selectedToken = filters.token.trim().toUpperCase();
+ const minPayerReputation = parseNumeric(filters.minPayerReputation);
return invoices.filter((invoice) => {
if (search) {
@@ -153,6 +167,13 @@ export function applyInvoiceFilters(
if (minDiscount !== null && invoice.discount_rate < minDiscount) return false;
if (maxDiscount !== null && invoice.discount_rate > maxDiscount) return false;
+ if (minPayerReputation !== null && minPayerReputation > 0) {
+ const payerReputation = options?.resolvePayerReputation?.(invoice);
+ if (payerReputation === null || payerReputation === undefined || payerReputation < minPayerReputation) {
+ return false;
+ }
+ }
+
return true;
});
}
@@ -190,6 +211,15 @@ export function useInvoiceFilters({ namespace }: UseInvoiceFiltersOptions) {
const activeFilterCount = useMemo(() => countActiveInvoiceFilters(filters), [filters]);
+ useEffect(() => {
+ const key = `${namespace}_minPayerReputation`;
+ if (filters.minPayerReputation.trim()) {
+ window.localStorage.setItem(key, filters.minPayerReputation.trim());
+ } else {
+ window.localStorage.removeItem(key);
+ }
+ }, [filters.minPayerReputation, namespace]);
+
return {
filters,
setFilters: updateFilters,
diff --git a/vitest.config.ts b/vitest.config.ts
index a222b59..f89dda0 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
- '@': path.resolve(__dirname, '.'),
+ '@': path.resolve(__dirname, 'src'),
},
},
test: {
|