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; +}