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: {