Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions app/marketplace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -76,6 +89,14 @@ function SkeletonRow() {
}

export default function MarketplacePage() {
const router = useRouter();
const pathname = usePathname();

const [sortState, setSortState] = useState<InvoiceSortState>(() => {
if (typeof window === "undefined") return DEFAULT_SORT_STATE;
return parseSortState(new URLSearchParams(window.location.search));
});

const {
data,
fetchNextPage,
Expand Down Expand Up @@ -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 (
Expand All @@ -176,6 +221,10 @@ export default function MarketplacePage() {
onClear={handleClear}
/>

<div className="mt-4">
<MarketplaceSortHeader sort={sortState} onSort={handleSort} />
</div>

{isFetching && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground my-4">
<Loader2 className="size-3 animate-spin" />
Expand Down
45 changes: 45 additions & 0 deletions components/marketplace/__tests__/sort-header.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MarketplaceSortHeader sort={DEFAULT_SORT_STATE} onSort={vi.fn()} />
);
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(
<MarketplaceSortHeader sort={DEFAULT_SORT_STATE} onSort={vi.fn()} />
);
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(
<MarketplaceSortHeader
sort={{ key: "faceValue", order: "asc" }}
onSort={vi.fn()}
/>
);
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(<MarketplaceSortHeader sort={DEFAULT_SORT_STATE} onSort={onSort} />);
fireEvent.click(screen.getByRole("button", { name: /sort by deadline/i }));
expect(onSort).toHaveBeenCalledWith("deadline");
});
});
1 change: 1 addition & 0 deletions components/marketplace/index.ts
Original file line number Diff line number Diff line change
@@ -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";
58 changes: 58 additions & 0 deletions components/marketplace/sort-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-end gap-1 text-sm">
<span className="mr-2 text-xs uppercase tracking-wide text-muted-foreground">
Sort by
</span>
{SORTABLE_COLUMNS.map(({ key, label }) => {
const active = sort.key === key;
return (
<button
key={key}
type="button"
onClick={() => onSort(key)}
aria-label={`Sort by ${label}`}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-muted-foreground transition-colors hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{label}
{active ? (
sort.order === "asc" ? (
<ArrowUp className="size-3" aria-hidden />
) : (
<ArrowDown className="size-3" aria-hidden />
)
) : (
<ArrowUpDown className="size-3 opacity-50" aria-hidden />
)}
</button>
);
})}
</div>
);
}
143 changes: 143 additions & 0 deletions lib/__tests__/invoice-sort.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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");
});
});
Loading