From 24c8bc36294863951e8784eba61864887219af59 Mon Sep 17 00:00:00 2001 From: apatafamilycompound123-ops <243656000+apatafamilycompound123-ops@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:45:32 +0100 Subject: [PATCH] feat(marketplace): add sortable face value and deadline columns Add clickable sort headers to the invoice marketplace for Face Value and Deadline. Each column cycles ascending, descending, then resets to the default order, with a direction arrow shown on the active column. The active sort is persisted in URL query params and reset when filters are cleared. Includes unit tests for the sorting helpers and sort header. --- app/marketplace/page.tsx | 57 ++++++- .../__tests__/sort-header.test.tsx | 45 ++++++ components/marketplace/index.ts | 1 + components/marketplace/sort-header.tsx | 58 +++++++ lib/__tests__/invoice-sort.test.ts | 143 ++++++++++++++++++ lib/invoice-sort.ts | 83 ++++++++++ 6 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 components/marketplace/__tests__/sort-header.test.tsx create mode 100644 components/marketplace/sort-header.tsx create mode 100644 lib/__tests__/invoice-sort.test.ts create mode 100644 lib/invoice-sort.ts diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index c727b8d..cee0339 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -2,11 +2,24 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { usePathname, useRouter } from "next/navigation"; import { fetchInvoices, type Invoice } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { MarketplaceFilterBar } from "@/components/marketplace"; +import { + MarketplaceFilterBar, + MarketplaceSortHeader, +} from "@/components/marketplace"; +import { + cycleSort, + DEFAULT_SORT_STATE, + parseSortState, + serializeSortState, + sortInvoices, + type InvoiceSortKey, + type InvoiceSortState, +} from "@/lib/invoice-sort"; import { Loader2 } from "lucide-react"; function InvoiceRow({ invoice }: { invoice: Invoice }) { @@ -76,6 +89,14 @@ function SkeletonRow() { } export default function MarketplacePage() { + const router = useRouter(); + const pathname = usePathname(); + + const [sortState, setSortState] = useState(() => { + if (typeof window === "undefined") return DEFAULT_SORT_STATE; + return parseSortState(new URLSearchParams(window.location.search)); + }); + const { data, fetchNextPage, @@ -139,17 +160,41 @@ export default function MarketplacePage() { setStatus("all"); setSearch(""); setDebouncedSearch(""); - }, []); + setSortState(DEFAULT_SORT_STATE); + if (typeof window !== "undefined") { + const params = serializeSortState( + DEFAULT_SORT_STATE, + new URLSearchParams(window.location.search) + ); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + } + }, [router, pathname]); + + const handleSort = useCallback( + (key: InvoiceSortKey) => { + const next = cycleSort(sortState, key); + setSortState(next); + if (typeof window !== "undefined") { + const params = serializeSortState( + next, + new URLSearchParams(window.location.search) + ); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + } + }, + [sortState, router, pathname] + ); const filtered = useMemo(() => { - return allInvoices.filter((inv) => { + const matches = allInvoices.filter((inv) => { const matchesStatus = status === "all" || inv.status === status; const matchesSearch = debouncedSearch === "" || inv.title.toLowerCase().includes(debouncedSearch.toLowerCase()); return matchesStatus && matchesSearch; }); - }, [allInvoices, status, debouncedSearch]); + return sortInvoices(matches, sortState); + }, [allInvoices, status, debouncedSearch, sortState]); if (isLoading) { return ( @@ -176,6 +221,10 @@ export default function MarketplacePage() { onClear={handleClear} /> +
+ +
+ {isFetching && !isLoading && (
diff --git a/components/marketplace/__tests__/sort-header.test.tsx b/components/marketplace/__tests__/sort-header.test.tsx new file mode 100644 index 0000000..8b3706b --- /dev/null +++ b/components/marketplace/__tests__/sort-header.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MarketplaceSortHeader } from "../sort-header"; +import { DEFAULT_SORT_STATE } from "@/lib/invoice-sort"; + +describe("MarketplaceSortHeader", () => { + it("renders both sortable column labels", () => { + render( + + ); + expect(screen.getByRole("button", { name: /sort by face value/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sort by deadline/i })).toBeInTheDocument(); + }); + + it("does not mark any column as active by default", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /sort by face value/i }) + ).toHaveAttribute("aria-pressed", "false"); + expect( + screen.getByRole("button", { name: /sort by deadline/i }) + ).toHaveAttribute("aria-pressed", "false"); + }); + + it("marks the active column as pressed", () => { + render( + + ); + expect( + screen.getByRole("button", { name: /sort by face value/i }) + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("calls onSort with the clicked column key", () => { + const onSort = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /sort by deadline/i })); + expect(onSort).toHaveBeenCalledWith("deadline"); + }); +}); diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts index eda6b4b..2da4667 100644 --- a/components/marketplace/index.ts +++ b/components/marketplace/index.ts @@ -1,3 +1,4 @@ export { MarketplaceFilterBar } from "./filter-bar"; +export { MarketplaceSortHeader } from "./sort-header"; export { InvoiceCard } from "./invoice-card"; export { CountdownTimer, isExpired } from "./countdown-timer"; diff --git a/components/marketplace/sort-header.tsx b/components/marketplace/sort-header.tsx new file mode 100644 index 0000000..ec21221 --- /dev/null +++ b/components/marketplace/sort-header.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { + InvoiceSortKey, + InvoiceSortState, +} from "@/lib/invoice-sort"; + +interface MarketplaceSortHeaderProps { + sort: InvoiceSortState; + onSort: (key: InvoiceSortKey) => void; +} + +const SORTABLE_COLUMNS: { key: InvoiceSortKey; label: string }[] = [ + { key: "faceValue", label: "Face Value" }, + { key: "deadline", label: "Deadline" }, +]; + +export function MarketplaceSortHeader({ + sort, + onSort, +}: MarketplaceSortHeaderProps) { + return ( +
+ + Sort by + + {SORTABLE_COLUMNS.map(({ key, label }) => { + const active = sort.key === key; + return ( + + ); + })} +
+ ); +} diff --git a/lib/__tests__/invoice-sort.test.ts b/lib/__tests__/invoice-sort.test.ts new file mode 100644 index 0000000..2517c19 --- /dev/null +++ b/lib/__tests__/invoice-sort.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import type { Invoice } from "@/lib/api"; +import { + cycleSort, + DEFAULT_SORT_STATE, + parseSortState, + serializeSortState, + sortInvoices, +} from "../invoice-sort"; + +const invoice = (overrides: Partial): Invoice => ({ + id: "1", + title: "Invoice", + seller: "seller", + amount: 1000, + raised: 500, + investor_count: 2, + status: "open", + due_date: "2026-08-01T00:00:00Z", + has_more: false, + next_cursor: null, + ...overrides, +}); + +describe("cycleSort", () => { + it("starts ascending when clicking an inactive column", () => { + expect(cycleSort(DEFAULT_SORT_STATE, "faceValue")).toEqual({ + key: "faceValue", + order: "asc", + }); + }); + + it("moves from ascending to descending on a second click", () => { + expect( + cycleSort({ key: "faceValue", order: "asc" }, "faceValue") + ).toEqual({ key: "faceValue", order: "desc" }); + }); + + it("resets to default on a third click", () => { + expect( + cycleSort({ key: "faceValue", order: "desc" }, "faceValue") + ).toEqual(DEFAULT_SORT_STATE); + }); + + it("resets an active sort when a different column is clicked", () => { + const next = cycleSort({ key: "deadline", order: "desc" }, "faceValue"); + expect(next).toEqual({ key: "faceValue", order: "asc" }); + }); +}); + +describe("sortInvoices", () => { + const invoices = [ + invoice({ id: "a", amount: 300, due_date: "2026-08-10T00:00:00Z" }), + invoice({ id: "b", amount: 100, due_date: "2026-08-01T00:00:00Z" }), + invoice({ id: "c", amount: 200, due_date: "2026-08-05T00:00:00Z" }), + ]; + + it("returns the input unchanged when no sort is active", () => { + expect(sortInvoices(invoices, DEFAULT_SORT_STATE)).toBe(invoices); + }); + + it("sorts by face value ascending", () => { + const sorted = sortInvoices(invoices, { key: "faceValue", order: "asc" }); + expect(sorted.map((i) => i.id)).toEqual(["b", "c", "a"]); + }); + + it("sorts by face value descending", () => { + const sorted = sortInvoices(invoices, { key: "faceValue", order: "desc" }); + expect(sorted.map((i) => i.id)).toEqual(["a", "c", "b"]); + }); + + it("sorts by deadline ascending (soonest first)", () => { + const sorted = sortInvoices(invoices, { key: "deadline", order: "asc" }); + expect(sorted.map((i) => i.id)).toEqual(["b", "c", "a"]); + }); + + it("sorts by deadline descending", () => { + const sorted = sortInvoices(invoices, { key: "deadline", order: "desc" }); + expect(sorted.map((i) => i.id)).toEqual(["a", "c", "b"]); + }); + + it("does not mutate the input array", () => { + const input = [...invoices]; + sortInvoices(invoices, { key: "faceValue", order: "desc" }); + expect(invoices).toEqual(input); + }); +}); + +describe("parseSortState", () => { + it("returns the default state when no sort param exists", () => { + expect(parseSortState(new URLSearchParams(""))).toEqual( + DEFAULT_SORT_STATE + ); + }); + + it("parses a valid sort and order", () => { + expect( + parseSortState(new URLSearchParams("sort=deadline&order=desc")) + ).toEqual({ key: "deadline", order: "desc" }); + }); + + it("defaults order to ascending when missing", () => { + expect(parseSortState(new URLSearchParams("sort=faceValue"))).toEqual({ + key: "faceValue", + order: "asc", + }); + }); + + it("ignores invalid sort keys", () => { + expect(parseSortState(new URLSearchParams("sort=title&order=asc"))).toEqual( + DEFAULT_SORT_STATE + ); + }); +}); + +describe("serializeSortState", () => { + it("sets sort and order params for an active sort", () => { + const params = serializeSortState( + { key: "faceValue", order: "desc" }, + new URLSearchParams("") + ); + expect(params.get("sort")).toBe("faceValue"); + expect(params.get("order")).toBe("desc"); + }); + + it("removes sort params when reset", () => { + const params = serializeSortState( + DEFAULT_SORT_STATE, + new URLSearchParams("sort=faceValue&order=asc") + ); + expect(params.has("sort")).toBe(false); + expect(params.has("order")).toBe(false); + }); + + it("preserves unrelated query params", () => { + const params = serializeSortState( + { key: "deadline", order: "asc" }, + new URLSearchParams("status=open") + ); + expect(params.get("status")).toBe("open"); + expect(params.get("sort")).toBe("deadline"); + }); +}); diff --git a/lib/invoice-sort.ts b/lib/invoice-sort.ts new file mode 100644 index 0000000..f67a862 --- /dev/null +++ b/lib/invoice-sort.ts @@ -0,0 +1,83 @@ +import type { Invoice } from "@/lib/api"; + +export type InvoiceSortKey = "faceValue" | "deadline"; +export type InvoiceSortOrder = "asc" | "desc"; + +export interface InvoiceSortState { + key: InvoiceSortKey | null; + order: InvoiceSortOrder; +} + +export const DEFAULT_SORT_STATE: InvoiceSortState = { + key: null, + order: "asc", +}; + +export const SORT_QUERY_KEY = "sort"; +export const ORDER_QUERY_KEY = "order"; + +/** + * Advances the sort state for a column: no sort -> ascending -> descending -> + * no sort (reset). Clicking a different column always starts ascending. + */ +export function cycleSort( + current: InvoiceSortState, + key: InvoiceSortKey +): InvoiceSortState { + if (current.key !== key) { + return { key, order: "asc" }; + } + if (current.order === "asc") { + return { key, order: "desc" }; + } + return DEFAULT_SORT_STATE; +} + +/** + * Returns a new array sorted by the active sort state. Returns the input + * unchanged when no column is active. + */ +export function sortInvoices( + invoices: Invoice[], + state: InvoiceSortState +): Invoice[] { + if (!state.key) { + return invoices; + } + + const factor = state.order === "asc" ? 1 : -1; + return [...invoices].sort((a, b) => { + if (state.key === "faceValue") { + return (a.amount - b.amount) * factor; + } + const aTime = new Date(a.due_date).getTime(); + const bTime = new Date(b.due_date).getTime(); + return (aTime - bTime) * factor; + }); +} + +/** Reads a sort state from URL query params. */ +export function parseSortState(params: URLSearchParams): InvoiceSortState { + const key = params.get(SORT_QUERY_KEY); + if (key !== "faceValue" && key !== "deadline") { + return DEFAULT_SORT_STATE; + } + const order = params.get(ORDER_QUERY_KEY) === "desc" ? "desc" : "asc"; + return { key, order }; +} + +/** Writes a sort state into a copy of the given URL query params. */ +export function serializeSortState( + state: InvoiceSortState, + params: URLSearchParams +): URLSearchParams { + const next = new URLSearchParams(params); + if (!state.key) { + next.delete(SORT_QUERY_KEY); + next.delete(ORDER_QUERY_KEY); + } else { + next.set(SORT_QUERY_KEY, state.key); + next.set(ORDER_QUERY_KEY, state.order); + } + return next; +}