From 97e64e52478cc7bab3bff87859b3d544b23a523b Mon Sep 17 00:00:00 2001 From: solomonadzape95 Date: Sun, 18 Oct 2026 04:07:05 +0100 Subject: [PATCH 1/4] feat(watchlist): investor watchlist page with state-change alerts (#474) - Add /watchlist page listing watched invoices as InvoiceCard components - Sync watchlist from the on-chain registry via the connected wallet - Surface recent state transitions (Now Funded / Matured / Settled) as badges - Add a bell WatchlistButton to InvoiceCard with optimistic add/remove and rollback - Add illustrated empty state with Browse Marketplace CTA - Redirect legacy /investor/watchlist route to /watchlist - Cover state-change detection, registry sync, optimistic rollback and page states with tests --- README.md | 1 + app/(dashboard)/investor/watchlist/page.tsx | 115 +------ .../__tests__/watchlist-page.test.tsx | 113 +++++++ app/(dashboard)/watchlist/page.tsx | 269 +++++++-------- components/invoices/WatchlistButton.tsx | 68 +--- components/layout/MobileNav.tsx | 2 +- components/marketplace/EmptyWatchlist.tsx | 59 ++++ components/marketplace/StateChangeBadge.tsx | 51 +++ components/marketplace/WatchlistButton.tsx | 76 ++++- .../invoice-card-suitability.test.tsx | 9 +- .../__tests__/invoice-card.test.tsx | 9 +- .../__tests__/render-with-card-providers.tsx | 28 ++ .../__tests__/watchlist-button.test.tsx | 144 ++++++++ components/marketplace/index.ts | 3 + components/marketplace/invoice-card.tsx | 38 ++- hooks/__tests__/useWatchlist.test.tsx | 247 ++++++++++++++ hooks/useWatchlist.ts | 312 ++++++++++++------ lib/__tests__/watchlist.test.ts | 138 ++++++++ lib/api/index.ts | 65 +++- lib/watchlist.ts | 161 +++++++++ 20 files changed, 1444 insertions(+), 464 deletions(-) create mode 100644 app/(dashboard)/watchlist/__tests__/watchlist-page.test.tsx create mode 100644 components/marketplace/EmptyWatchlist.tsx create mode 100644 components/marketplace/StateChangeBadge.tsx create mode 100644 components/marketplace/__tests__/render-with-card-providers.tsx create mode 100644 components/marketplace/__tests__/watchlist-button.test.tsx create mode 100644 hooks/__tests__/useWatchlist.test.tsx create mode 100644 lib/__tests__/watchlist.test.ts create mode 100644 lib/watchlist.ts diff --git a/README.md b/README.md index 79ab5d2..bb82542 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ lib/ - **Sellers**: `(dashboard)/seller` — publish (`PublishInvoiceForm`, `publishInvoice()`), edit, and track invoices via `SellerDashboard`/`fetchSellerDashboard()`. - **Investors**: `/marketplace` browse + filter (`FilterPanel`, `filter-bar`), `/marketplace/[id]` detail + invest (`InvestDialog`/`InvestmentModal` → `investInInvoice()`), `(dashboard)/investor` portfolio (`InvestorPortfolio`, `fetchPortfolio()`), payout history (`PayoutHistoryTable`, `fetchInvestorPayouts()`). +- **Watchlist** (#474): `/watchlist` lists the connected wallet's watched invoices as `InvoiceCard`s, each with a bell toggle (`WatchlistButton`) and a recent state-change badge (`StateChangeBadge`). `useWatchlist` reads the on-chain registry via `fetchWatchlistRegistry()` and layers optimistic add/remove on top, rolling back when the registry call fails. `/investor/watchlist` redirects here. - Invoices carry `status`: `"open" | "funded" | "settled" | "rejected" | "draft"`, a `yield_percentage`, and support position transfer between investors (`transferInvoicePosition()`). - Documents attach via `uploadDocumentToIpfs()` (`DocumentUpload`/`DocumentPreview`). - A leaderboard (`TopInvestorsLeaderboard`, `useLeaderboard`) and a recently-viewed list (`useRecentlyViewed`, backed by `lib/recentlyViewed.ts`) appear on the landing page. diff --git a/app/(dashboard)/investor/watchlist/page.tsx b/app/(dashboard)/investor/watchlist/page.tsx index 404ac41..cf9e1c5 100644 --- a/app/(dashboard)/investor/watchlist/page.tsx +++ b/app/(dashboard)/investor/watchlist/page.tsx @@ -1,105 +1,12 @@ -"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { EyeOff, ExternalLink } from "lucide-react"; - -const WATCHLIST_KEY = "stellarsettle:watchlist"; - -function getWatchlist(): string[] { - if (typeof window === "undefined") return []; - try { - return JSON.parse(localStorage.getItem(WATCHLIST_KEY) ?? "[]"); - } catch { - return []; - } -} - -function saveWatchlist(ids: string[]): void { - localStorage.setItem(WATCHLIST_KEY, JSON.stringify(ids)); -} - -export default function WatchlistPage() { - const [watchedIds, setWatchedIds] = useState([]); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setWatchedIds(getWatchlist()); - setMounted(true); - }, []); - - const removeInvoice = (id: string) => { - const next = watchedIds.filter((wid) => wid !== id); - setWatchedIds(next); - saveWatchlist(next); - }; - - if (!mounted) { - return ( -
-

Watchlist

-

Loading...

-
- ); - } - - return ( -
-

Watchlist

- - {watchedIds.length === 0 ? ( - - -

- No invoices in your watchlist yet. -

- - Browse the marketplace to find invoices to watch - -
-
- ) : ( -
- {watchedIds.map((id) => ( - - -
-
- {id.slice(0, 2)} -
-
-

{id}

-

- Added to watchlist -

-
-
-
- - - - -
-
-
- ))} -
- )} -
- ); +import { redirect } from "next/navigation"; + +/** + * The investor watchlist lives at `/watchlist` (#474). + * + * This route is kept as an alias because the mobile navigation used to link + * here; it permanently forwards so there is only one watchlist implementation + * reading from the on-chain registry. + */ +export default function InvestorWatchlistPage() { + redirect("/watchlist"); } diff --git a/app/(dashboard)/watchlist/__tests__/watchlist-page.test.tsx b/app/(dashboard)/watchlist/__tests__/watchlist-page.test.tsx new file mode 100644 index 0000000..65e38df --- /dev/null +++ b/app/(dashboard)/watchlist/__tests__/watchlist-page.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import WatchlistPage from "../page"; +import * as api from "@/lib/api"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn() }, +})); + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush, replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => "/watchlist", + useSearchParams: () => new URLSearchParams(), +})); + +const walletState = vi.hoisted(() => ({ + address: "GADDRESSWATCHLIST" as string | null, + isConnected: true, +})); + +vi.mock("@/hooks/useStellarWallet", () => ({ + useStellarWallet: () => walletState, +})); + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render(, { wrapper: Wrapper }); +} + +function makeInvoice(overrides: Partial = {}): api.InvoiceDetail { + return { + id: "inv-1", + title: "Acme receivable", + seller: "GABC", + amount: 1000, + raised: 250, + investor_count: 2, + status: "open", + due_date: "2030-01-01T00:00:00.000Z", + has_more: false, + next_cursor: null, + description: "A test receivable", + investors: [], + document_url: "https://example.com/doc.pdf", + ...overrides, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + walletState.address = "GADDRESSWATCHLIST"; + walletState.isConnected = true; +}); + +describe("WatchlistPage (#474)", () => { + it("renders the empty state with a marketplace CTA when nothing is watched", async () => { + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ items: [] }); + + renderPage(); + + expect(await screen.findByTestId("watchlist-empty-state")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Browse Marketplace/i }), + ).toHaveAttribute("href", "/marketplace"); + }); + + it("lists watched invoices as cards with state-change badges", async () => { + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ + items: [ + { + invoice_id: "inv-1", + added_at: new Date().toISOString(), + last_seen_status: "open", + last_state_change_at: new Date().toISOString(), + }, + ], + }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue( + makeInvoice({ status: "funded", funded_at: new Date().toISOString() }), + ); + + renderPage(); + + expect(await screen.findByTestId("invoice-card-inv-1")).toBeInTheDocument(); + const badge = screen.getByTestId("state-change-badge"); + expect(badge).toHaveAttribute("data-change-kind", "now_funded"); + expect(badge).toHaveTextContent("Now Funded"); + }); + + it("prompts to connect a wallet when disconnected", async () => { + walletState.address = null; + walletState.isConnected = false; + const fetchSpy = vi.spyOn(api, "fetchWatchlistRegistry"); + + renderPage(); + + expect( + await screen.findByTestId("watchlist-connect-wallet"), + ).toBeInTheDocument(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(dashboard)/watchlist/page.tsx b/app/(dashboard)/watchlist/page.tsx index 42e333e..d750fcd 100644 --- a/app/(dashboard)/watchlist/page.tsx +++ b/app/(dashboard)/watchlist/page.tsx @@ -1,174 +1,143 @@ "use client"; -import { format } from "date-fns"; -import { Trash2, ExternalLink, Loader2 } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Bell, Loader2, RefreshCw, Wallet } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { + ComparisonProvider, + EmptyWatchlist, + InvoiceCard, +} from "@/components/marketplace"; import { useWatchlist } from "@/hooks/useWatchlist"; -import { fetchInvoices, type Invoice } from "@/lib/api"; -import { useQuery } from "@tanstack/react-query"; -import Link from "next/link"; import { useStellarWallet } from "@/hooks/useStellarWallet"; -import { cn } from "@/lib/utils"; -import { Money } from "@/components/currency"; +import { usePageTitle } from "@/hooks/usePageTitle"; +/** + * Investor watchlist (#474). + * + * Lists the wallets's watched invoices as full `InvoiceCard`s, each carrying a + * state-change badge and a bell to unwatch. The list itself comes from the + * on-chain registry via the connected wallet. + */ export default function WatchlistPage() { - const { address, isConnected } = useStellarWallet(); - const { items, isLoading: watchlistLoading, toggle, isBookmarked, count } = useWatchlist(); + usePageTitle("Watchlist"); + const router = useRouter(); + const { isConnected } = useStellarWallet(); + const { invoices, count, isLoading, isError, refetch, isPending } = + useWatchlist(); - const { data: invoicesData, isLoading: invoicesLoading } = useQuery({ - queryKey: ["watchlist-invoices", items.join(",")], - queryFn: async () => { - if (items.length === 0) return []; - const results = await Promise.all( - items.map(async (id) => { - try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL ?? "/api"}/invoices/${id}`); - if (res.ok) return res.json(); - return null; - } catch { - return null; - } - }) - ); - return results.filter((inv): inv is Invoice => inv !== null); - }, - enabled: items.length > 0, - }); - - if (!isConnected) { - return ( -
-
-

Watchlist

-

- Connect your wallet to view and manage your bookmarked invoices + return ( +

+
+
+

+

+

+ {isConnected + ? `Invoices you are monitoring (${count})` + : "Connect your wallet to view the invoices you are monitoring"}

-
- ); - } - - const isLoading = watchlistLoading || invoicesLoading; - const invoices = invoicesData ?? []; + {isConnected && count > 0 && ( + + )} +
- if (isLoading) { - return ( -
-
-

Watchlist

-

Your bookmarked invoices

+ {!isConnected ? ( +
+
-
- {Array.from({ length: 5 }).map((_, i) => ( - + ) : isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + ))}
-
- ); - } - - return ( -
-
-

Watchlist

-

Your bookmarked invoices ({count})

-
- - {invoices.length === 0 ? ( - - -

No bookmarked invoices yet

- -
-
- ) : ( -
- {invoices.map((invoice) => ( - toggle(invoice.id)} - /> - ))} + ) : isError ? ( +
+

Couldn't load your watchlist

+

+ The registry did not respond. Check your connection and try again. +

+
+ ) : invoices.length === 0 ? ( + + ) : ( + +
+ {invoices.map(({ invoice, stateChange }) => ( + router.push(`/marketplace/${invoice.id}`)} + /> + ))} +
+
)}
); } -function WatchlistItem({ - invoice, - onRemove, -}: { - invoice: Invoice; - onRemove: () => void; -}) { +function WatchlistCardSkeleton() { return ( - - -
-
- - {invoice.title} - -
- - {invoice.investor_count} investors - Due: {format(new Date(invoice.due_date), "MMM d, yyyy")} -
-
-
- - -
-
-
-
+
+
+ + +
+ + +
+ + +
+
+ + + +
+
); } - -function WatchlistSkeleton() { - return ( - - -
-
- -
- - - -
-
-
- - -
-
-
-
- ); -} \ No newline at end of file diff --git a/components/invoices/WatchlistButton.tsx b/components/invoices/WatchlistButton.tsx index 1f666ad..11509b2 100644 --- a/components/invoices/WatchlistButton.tsx +++ b/components/invoices/WatchlistButton.tsx @@ -1,69 +1,25 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Eye, EyeOff } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -const WATCHLIST_KEY = "stellarsettle:watchlist"; - -function getWatchlist(): string[] { - if (typeof window === "undefined") return []; - try { - return JSON.parse(localStorage.getItem(WATCHLIST_KEY) ?? "[]"); - } catch { - return []; - } -} - -function saveWatchlist(ids: string[]): void { - localStorage.setItem(WATCHLIST_KEY, JSON.stringify(ids)); -} +import { WatchlistButton as BaseWatchlistButton } from "@/components/marketplace/WatchlistButton"; interface WatchlistButtonProps { invoiceId: string; className?: string; } +/** + * Invoice-detail variant of the watchlist bell (#474). + * + * The detail header has room for copy, so this renders the labelled form of + * the shared button. Both surfaces share one registry-backed hook, so the + * detail page and the watchlist page can never disagree about what is watched. + */ export function WatchlistButton({ invoiceId, className }: WatchlistButtonProps) { - const [watched, setWatched] = useState(false); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setWatched(getWatchlist().includes(invoiceId)); - setMounted(true); - }, [invoiceId]); - - const toggle = useCallback(() => { - setWatched((prev) => { - const list = getWatchlist(); - const next = !prev; - if (next) { - if (!list.includes(invoiceId)) list.push(invoiceId); - } else { - const idx = list.indexOf(invoiceId); - if (idx !== -1) list.splice(idx, 1); - } - saveWatchlist(list); - return next; - }); - }, [invoiceId]); - - if (!mounted) return null; - return ( - + showLabel + /> ); } diff --git a/components/layout/MobileNav.tsx b/components/layout/MobileNav.tsx index 6f02c3f..01b5b4a 100644 --- a/components/layout/MobileNav.tsx +++ b/components/layout/MobileNav.tsx @@ -16,7 +16,7 @@ const TABS = [ const SECONDARY_LINKS = [ { href: "/marketplace/resale", label: "Secondary market" }, - { href: "/investor/watchlist", label: "Watchlist" }, + { href: "/watchlist", label: "Watchlist" }, { href: "/transactions", label: "Transactions" }, { href: "/investor/keys", label: "Keys" }, { href: "/kyc", label: "KYC" }, diff --git a/components/marketplace/EmptyWatchlist.tsx b/components/marketplace/EmptyWatchlist.tsx new file mode 100644 index 0000000..5ad8cfa --- /dev/null +++ b/components/marketplace/EmptyWatchlist.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { Bell, Compass } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** + * Empty state for the investor watchlist (#474). + * + * A blank list is a dead end, so this pairs a small illustration with the one + * action that fills it: browsing the marketplace. + */ +export function EmptyWatchlist() { + return ( +
+ + +

No watched invoices yet

+

+ Watch invoices you are interested in and they will appear here, with a + badge whenever one is funded, matures, or settles. +

+ + +
+ ); +} diff --git a/components/marketplace/StateChangeBadge.tsx b/components/marketplace/StateChangeBadge.tsx new file mode 100644 index 0000000..15c78e1 --- /dev/null +++ b/components/marketplace/StateChangeBadge.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { CheckCircle2, Hourglass, Rocket, Sparkles } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { + WatchlistStateChange, + WatchlistStateChangeKind, +} from "@/lib/watchlist"; + +interface StateChangeBadgeProps { + change: WatchlistStateChange; + className?: string; +} + +const ICONS: Record = { + now_funded: Rocket, + matured: Hourglass, + settled: CheckCircle2, + now_open: Sparkles, +}; + +const TONE_CLASSES: Record = { + now_funded: "border-transparent bg-emerald-100 text-emerald-900", + matured: "border-amber-300 bg-amber-100 text-amber-900", + settled: "border-transparent bg-sky-100 text-sky-900", + now_open: "border-transparent bg-primary/10 text-primary", +}; + +/** + * Pill announcing a recent transition on a watched invoice (#474). + * + * Rendered by the watchlist card and, when supplied, by `InvoiceCard` itself so + * the signal follows the invoice wherever the investor sees it. + */ +export function StateChangeBadge({ change, className }: StateChangeBadgeProps) { + const Icon = ICONS[change.kind]; + + return ( + + + ); +} diff --git a/components/marketplace/WatchlistButton.tsx b/components/marketplace/WatchlistButton.tsx index 7f3d26d..0d35b2e 100644 --- a/components/marketplace/WatchlistButton.tsx +++ b/components/marketplace/WatchlistButton.tsx @@ -1,28 +1,84 @@ "use client"; -import { Bookmark, BookmarkCheck } from "lucide-react"; +import { Bell, BellRing } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useStellarWallet } from "@/hooks/useStellarWallet"; import { useWatchlist } from "@/hooks/useWatchlist"; +import { cn } from "@/lib/utils"; interface WatchlistButtonProps { invoiceId: string; className?: string; + /** Render "Watch"/"Watching" beside the bell instead of icon-only. */ + showLabel?: boolean; } -export function WatchlistButton({ invoiceId, className }: WatchlistButtonProps) { - const { toggle, isBookmarked, isPending } = useWatchlist(); - const bookmarked = isBookmarked(invoiceId); +/** + * Bell toggle for the watchlist (#474). + * + * Wired to `useWatchlist`, so clicking flips the bell optimistically and the + * hook rolls it back if the registry call fails. Without a connected wallet + * there is no registry row to write, so the control is disabled and explains + * why on hover rather than failing silently. + */ +export function WatchlistButton({ + invoiceId, + className, + showLabel = false, +}: WatchlistButtonProps) { + const { isConnected } = useStellarWallet(); + const { isWatched, isPending, toggle } = useWatchlist(); + + const watched = isWatched(invoiceId); + const pending = isPending(invoiceId); + const disabled = !isConnected || pending; + const label = watched ? "Remove from watchlist" : "Add to watchlist"; + const title = !isConnected + ? "Connect your wallet to watch invoices" + : label; + + if (showLabel) { + return ( + + ); + } return ( ); -} \ No newline at end of file +} diff --git a/components/marketplace/__tests__/invoice-card-suitability.test.tsx b/components/marketplace/__tests__/invoice-card-suitability.test.tsx index fb091b5..b294a59 100644 --- a/components/marketplace/__tests__/invoice-card-suitability.test.tsx +++ b/components/marketplace/__tests__/invoice-card-suitability.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { InvoiceCard } from "../invoice-card"; +import { renderWithCardProviders } from "./render-with-card-providers"; import type { Invoice } from "@/lib/api"; function makeInvoice(grade?: "A" | "B" | "C" | "D"): Invoice { @@ -24,20 +25,20 @@ afterEach(() => localStorage.clear()); describe("InvoiceCard suitability gate (#391)", () => { it("locks a grade-D invoice for a moderate investor and names the required tier", () => { localStorage.setItem("ss-suitability-tier", "moderate"); - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Locked" })).toBeDisabled(); expect(screen.getByTestId("suitability-lock")).toHaveTextContent("Requires risk profile: Aggressive or higher"); }); it("allows a grade-C invoice for a moderate investor", () => { localStorage.setItem("ss-suitability-tier", "moderate"); - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Invest" })).toBeEnabled(); expect(screen.queryByTestId("suitability-lock")).toBeNull(); }); it("never gates unrated invoices", () => { - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Invest" })).toBeEnabled(); }); }); diff --git a/components/marketplace/__tests__/invoice-card.test.tsx b/components/marketplace/__tests__/invoice-card.test.tsx index da6d3c7..f447efd 100644 --- a/components/marketplace/__tests__/invoice-card.test.tsx +++ b/components/marketplace/__tests__/invoice-card.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { act, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { InvoiceCard } from "../invoice-card"; -import { ComparisonProvider } from "@/components/marketplace/InvoiceComparisonContext"; +import { renderWithCardProviders } from "./render-with-card-providers"; import type { Invoice } from "@/lib/api"; const TARGET = 10000; @@ -24,9 +24,10 @@ function makeInvoice(overrides: Partial = {}): Invoice { }; } -/** The card renders an "Add to compare" control that reads the compare context. */ +/** The card renders a compare toggle and a watchlist bell, both of which read + * from providers supplied by `renderWithCardProviders`. */ function renderCard(ui: ReactElement) { - return render({ui}); + return renderWithCardProviders(ui); } beforeEach(() => { diff --git a/components/marketplace/__tests__/render-with-card-providers.tsx b/components/marketplace/__tests__/render-with-card-providers.tsx new file mode 100644 index 0000000..0d20183 --- /dev/null +++ b/components/marketplace/__tests__/render-with-card-providers.tsx @@ -0,0 +1,28 @@ +import { render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { ComparisonProvider } from "@/components/marketplace/InvoiceComparisonContext"; + +/** + * Renders a component with the providers `InvoiceCard` needs now that it + * carries the watchlist bell (#474): React Query for the registry hook and the + * comparison context for the compare toggle. + * + * The wallet context intentionally stays at its disconnected default — these + * tests are about the card, not the watchlist, and a disconnected wallet simply + * disables the bell. + */ +export function renderWithCardProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} diff --git a/components/marketplace/__tests__/watchlist-button.test.tsx b/components/marketplace/__tests__/watchlist-button.test.tsx new file mode 100644 index 0000000..47e88fd --- /dev/null +++ b/components/marketplace/__tests__/watchlist-button.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { WatchlistButton } from "../WatchlistButton"; +import * as api from "@/lib/api"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn() }, +})); + +const walletState = vi.hoisted(() => ({ + address: "GADDRESSWATCHLIST" as string | null, + isConnected: true, +})); + +vi.mock("@/hooks/useStellarWallet", () => ({ + useStellarWallet: () => walletState, +})); + +/** + * Backs the registry calls with an in-memory set so a refetch after a mutation + * observes the new state, the way the real registry would. + */ +function mockRegistry(initial: string[] = []) { + const watched = new Set(initial); + vi.spyOn(api, "fetchWatchlistRegistry").mockImplementation(async () => ({ + items: [...watched].map((invoice_id) => ({ + invoice_id, + added_at: new Date().toISOString(), + })), + })); + vi.spyOn(api, "fetchInvoiceDetail").mockImplementation(async (id) => + makeInvoice({ id }), + ); + vi.spyOn(api, "watchInvoiceOnRegistry").mockImplementation( + async (_address, invoiceId) => { + watched.add(invoiceId); + return { success: true }; + }, + ); + vi.spyOn(api, "unwatchInvoiceOnRegistry").mockImplementation( + async (_address, invoiceId) => { + watched.delete(invoiceId); + return { success: true }; + }, + ); + return watched; +} + +function renderButton(invoiceId = "inv-1") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + , + ); +} + +function makeInvoice(overrides: Partial = {}): api.InvoiceDetail { + return { + id: "inv-1", + title: "Acme receivable", + seller: "GABC", + amount: 1000, + raised: 0, + investor_count: 0, + status: "open", + due_date: "2030-01-01T00:00:00.000Z", + has_more: false, + next_cursor: null, + description: "A test receivable", + investors: [], + document_url: "https://example.com/doc.pdf", + ...overrides, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + walletState.address = "GADDRESSWATCHLIST"; + walletState.isConnected = true; +}); + +describe("WatchlistButton (#474)", () => { + it("shows an unwatched bell and submits a watch call on click", async () => { + mockRegistry([]); + const watchSpy = vi.mocked(api.watchInvoiceOnRegistry); + + renderButton(); + const button = await screen.findByRole("button", { + name: "Add to watchlist", + }); + expect(button).toHaveAttribute("data-watched", "false"); + + await userEvent.click(button); + + expect(watchSpy).toHaveBeenCalledWith("GADDRESSWATCHLIST", "inv-1"); + expect( + await screen.findByRole("button", { name: "Remove from watchlist" }), + ).toHaveAttribute("data-watched", "true"); + }); + + it("shows a ringing bell for a watched invoice and submits an unwatch call", async () => { + mockRegistry(["inv-1"]); + const unwatchSpy = vi.mocked(api.unwatchInvoiceOnRegistry); + + renderButton(); + const button = await screen.findByRole("button", { + name: "Remove from watchlist", + }); + expect(button).toHaveAttribute("data-watched", "true"); + + await userEvent.click(button); + + expect(unwatchSpy).toHaveBeenCalledWith("GADDRESSWATCHLIST", "inv-1"); + expect( + await screen.findByRole("button", { name: "Add to watchlist" }), + ).toHaveAttribute("data-watched", "false"); + }); + + it("disables the bell and explains why when no wallet is connected", async () => { + walletState.address = null; + walletState.isConnected = false; + mockRegistry([]); + + renderButton(); + const button = await screen.findByRole("button", { + name: "Add to watchlist", + }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute( + "title", + "Connect your wallet to watch invoices", + ); + }); +}); diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts index 539e020..75fc01c 100644 --- a/components/marketplace/index.ts +++ b/components/marketplace/index.ts @@ -25,3 +25,6 @@ export { InvestorDemandMetrics } from "./InvestorDemandMetrics"; export { FeaturedInvoicesCarousel } from "./FeaturedInvoicesCarousel"; export { YieldCalculator } from "./YieldCalculator"; export { MyListings } from "./MyListings"; +export { StateChangeBadge } from "./StateChangeBadge"; +export { WatchlistButton } from "./WatchlistButton"; +export { EmptyWatchlist } from "./EmptyWatchlist"; diff --git a/components/marketplace/invoice-card.tsx b/components/marketplace/invoice-card.tsx index f9e3380..83f2537 100644 --- a/components/marketplace/invoice-card.tsx +++ b/components/marketplace/invoice-card.tsx @@ -8,11 +8,12 @@ import { InvoiceClassificationBadges } from "./InvoiceClassificationBadges"; import { CompareToggleButton } from "./CompareToggleButton"; import { SettlementCountdown } from "./SettlementCountdown"; import { InvestorDemandMetrics } from "./InvestorDemandMetrics"; -import { CompareToggleButton } from "./CompareToggleButton"; -import { useComparison } from "./InvoiceComparisonContext"; import { ShareInvoiceButton } from "@/components/invoices/ShareInvoiceButton"; -import { Lock, Scale } from "lucide-react"; +import { StateChangeBadge } from "./StateChangeBadge"; +import { WatchlistButton } from "./WatchlistButton"; +import { Lock } from "lucide-react"; import type { Invoice } from "@/lib/api"; +import type { WatchlistStateChange } from "@/lib/watchlist"; import { Money } from "@/components/currency"; import { useSuitabilityTier } from "@/hooks/useSuitabilityTier"; import { REQUIRED_TIER, TIER_LABELS, canInvestInGrade } from "@/lib/suitability"; @@ -26,9 +27,18 @@ const statusVariant: Record void; + /** Recent state transition to announce on the card (#474). */ + stateChange?: WatchlistStateChange | null; + /** Hide the watch bell on surfaces where watching is not the point. */ + showWatchToggle?: boolean; } -export function InvoiceCard({ invoice, onInvest }: InvoiceCardProps) { +export function InvoiceCard({ + invoice, + onInvest, + stateChange = null, + showWatchToggle = true, +}: InvoiceCardProps) { const published = invoice.status === "open"; const expired = isExpired(invoice.due_date); const { tier } = useSuitabilityTier(); @@ -36,15 +46,21 @@ export function InvoiceCard({ invoice, onInvest }: InvoiceCardProps) { const locked = !canInvestInGrade(tier, grade); return ( - +
- - {invoice.title} - - - {invoice.status} - +
+ + {invoice.title} + + {stateChange && } +
+
+ + {invoice.status} + + {showWatchToggle && } +
diff --git a/hooks/__tests__/useWatchlist.test.tsx b/hooks/__tests__/useWatchlist.test.tsx new file mode 100644 index 0000000..22e33da --- /dev/null +++ b/hooks/__tests__/useWatchlist.test.tsx @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useWatchlist } from "../useWatchlist"; +import * as api from "@/lib/api"; +import { toast } from "sonner"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn() }, +})); + +const walletState = vi.hoisted(() => ({ + address: "GADDRESSWATCHLIST" as string | null, + isConnected: true, +})); + +vi.mock("@/hooks/useStellarWallet", () => ({ + useStellarWallet: () => walletState, +})); + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +function makeInvoice(overrides: Partial = {}): api.InvoiceDetail { + return { + id: "inv-1", + title: "Acme receivable", + seller: "GABC", + amount: 1000, + raised: 0, + investor_count: 0, + status: "open", + due_date: new Date("2030-01-01T00:00:00.000Z").toISOString(), + has_more: false, + next_cursor: null, + description: "A test receivable", + investors: [], + document_url: "https://example.com/doc.pdf", + ...overrides, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + walletState.address = "GADDRESSWATCHLIST"; + walletState.isConnected = true; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useWatchlist registry sync (#474)", () => { + it("reads the connected wallet's registry and hydrates watched invoices", async () => { + const queryClient = makeQueryClient(); + const fetchSpy = vi + .spyOn(api, "fetchWatchlistRegistry") + .mockResolvedValue({ + items: [ + { + invoice_id: "inv-1", + added_at: new Date().toISOString(), + }, + ], + }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue(makeInvoice()); + + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.count).toBe(1)); + + expect(fetchSpy).toHaveBeenCalledWith("GADDRESSWATCHLIST"); + expect(result.current.isWatched("inv-1")).toBe(true); + expect(result.current.invoices).toHaveLength(1); + expect(result.current.invoices[0].invoice.title).toBe("Acme receivable"); + }); + + it("attaches a recent state-change badge derived from the registry transition", async () => { + const queryClient = makeQueryClient(); + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ + items: [ + { + invoice_id: "inv-1", + added_at: new Date().toISOString(), + last_seen_status: "open", + last_state_change_at: new Date().toISOString(), + }, + ], + }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue( + makeInvoice({ status: "funded" }), + ); + + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => + expect(result.current.invoices[0]?.stateChange?.kind).toBe("now_funded"), + ); + expect(result.current.invoices[0].stateChange?.label).toBe("Now Funded"); + }); + + it("does not fetch the registry or invoices when no wallet is connected", async () => { + walletState.address = null; + walletState.isConnected = false; + + const queryClient = makeQueryClient(); + const fetchSpy = vi.spyOn(api, "fetchWatchlistRegistry"); + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.current.count).toBe(0); + }); +}); + +describe("useWatchlist optimistic mutations (#474)", () => { + it("adds an invoice optimistically before the registry call resolves", async () => { + const queryClient = makeQueryClient(); + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ items: [] }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue(makeInvoice()); + + let resolveWatch!: (value: { success: boolean }) => void; + const watchPromise = new Promise<{ success: boolean }>((resolve) => { + resolveWatch = resolve; + }); + vi.spyOn(api, "watchInvoiceOnRegistry").mockReturnValue(watchPromise); + + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.toggle("inv-1"); + }); + + await waitFor(() => { + const cached = queryClient.getQueryData([ + "watchlist", + "GADDRESSWATCHLIST", + ]); + expect(cached?.items.map((item) => item.invoice_id)).toContain("inv-1"); + }); + expect(result.current.isWatched("inv-1")).toBe(true); + + await act(async () => { + resolveWatch({ success: true }); + }); + }); + + it("reverts the optimistic add and toasts when the registry call fails", async () => { + const queryClient = makeQueryClient(); + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ items: [] }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue(makeInvoice()); + + let rejectWatch!: (error: Error) => void; + const watchPromise = new Promise<{ success: boolean }>((_resolve, reject) => { + rejectWatch = reject; + }); + vi.spyOn(api, "watchInvoiceOnRegistry").mockReturnValue(watchPromise); + + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.toggle("inv-1"); + }); + + await waitFor(() => expect(result.current.isWatched("inv-1")).toBe(true)); + + await act(async () => { + rejectWatch(new Error("contract call failed")); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Couldn't add to watchlist. Please try again.", + ); + expect(result.current.isWatched("inv-1")).toBe(false); + }); + + const cached = queryClient.getQueryData([ + "watchlist", + "GADDRESSWATCHLIST", + ]); + expect(cached?.items).toHaveLength(0); + }); + + it("removes an invoice optimistically and reverts when the call fails", async () => { + const queryClient = makeQueryClient(); + vi.spyOn(api, "fetchWatchlistRegistry").mockResolvedValue({ + items: [{ invoice_id: "inv-1", added_at: new Date().toISOString() }], + }); + vi.spyOn(api, "fetchInvoiceDetail").mockResolvedValue(makeInvoice()); + + let rejectUnwatch!: (error: Error) => void; + const unwatchPromise = new Promise<{ success: boolean }>((_resolve, reject) => { + rejectUnwatch = reject; + }); + vi.spyOn(api, "unwatchInvoiceOnRegistry").mockReturnValue(unwatchPromise); + + const { result } = renderHook(() => useWatchlist(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isWatched("inv-1")).toBe(true)); + + act(() => { + result.current.toggle("inv-1"); + }); + + await waitFor(() => expect(result.current.isWatched("inv-1")).toBe(false)); + + await act(async () => { + rejectUnwatch(new Error("contract call failed")); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Couldn't remove from watchlist. Please try again.", + ); + expect(result.current.isWatched("inv-1")).toBe(true); + }); + }); +}); diff --git a/hooks/useWatchlist.ts b/hooks/useWatchlist.ts index 3c62a15..5c7f383 100644 --- a/hooks/useWatchlist.ts +++ b/hooks/useWatchlist.ts @@ -1,160 +1,254 @@ "use client"; +import { useCallback, useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState, useEffect } from "react"; +import { toast } from "sonner"; import { - fetchWatchlist, - addToWatchlist, - removeFromWatchlist, - type WatchlistItem, - type WatchlistResponse, + fetchInvoiceDetail, + fetchWatchlistRegistry, + watchInvoiceOnRegistry, + unwatchInvoiceOnRegistry, + type Invoice, + type InvoiceDetail, + type WatchlistRegistryResponse, } from "@/lib/api"; +import { detectStateChange, type WatchlistStateChange } from "@/lib/watchlist"; import { useStellarWallet } from "./useStellarWallet"; -import { toast } from "sonner"; -const WATCHLIST_KEY = "local_watchlist"; +/** + * Investor watchlist (#474). + * + * The on-chain registry is the source of truth. This hook reads it for the + * connected wallet, hydrates each watched id into a full `Invoice` for the + * cards, and layers optimistic add/remove mutations on top — the bell flips + * immediately and rolls back if the registry call fails. + */ + +export const WATCHLIST_QUERY_KEY = ["watchlist"] as const; + +export function watchlistQueryKey(address: string | null) { + return ["watchlist", address] as const; +} -function getLocalWatchlist(): string[] { - if (typeof window === "undefined") return []; - try { - return JSON.parse(localStorage.getItem(WATCHLIST_KEY) ?? "[]"); - } catch { - return []; - } +export function watchlistInvoicesQueryKey( + address: string | null, + ids: readonly string[], +) { + // Sorted so the key is stable regardless of the order the registry returned + // its entries in. + return ["watchlist-invoices", address, [...ids].sort().join(",")] as const; } -function setLocalWatchlist(items: string[]) { - if (typeof window === "undefined") return; - localStorage.setItem(WATCHLIST_KEY, JSON.stringify(items)); +export interface WatchedInvoice { + invoice: Invoice; + /** Recent transition to surface as a badge, or `null` when nothing changed. */ + stateChange: WatchlistStateChange | null; } -export function useWatchlist() { +export interface UseWatchlistResult { + entries: WatchlistRegistryResponse["items"]; + invoices: WatchedInvoice[]; + count: number; + isLoading: boolean; + isError: boolean; + isConnected: boolean; + isWatched: (invoiceId: string) => boolean; + isPending: (invoiceId?: string) => boolean; + refetch: () => void; + toggle: (invoiceId: string) => void; + add: (invoiceId: string) => void; + remove: (invoiceId: string) => void; +} + +export function useWatchlist(): UseWatchlistResult { const { address, isConnected } = useStellarWallet(); const queryClient = useQueryClient(); - - const { data: serverWatchlist, isLoading } = useQuery({ - queryKey: ["watchlist", address], - queryFn: fetchWatchlist, - enabled: isConnected && !!address, + const registryKey = watchlistQueryKey(address); + + const registryQuery = useQuery({ + queryKey: registryKey, + queryFn: () => fetchWatchlistRegistry(address as string), + // Without a wallet there is no registry row to read; the page shows a + // connect prompt instead. + enabled: isConnected && Boolean(address), staleTime: 30_000, }); - const serverItems = serverWatchlist?.items.map((i) => i.invoice_id) ?? []; - const [localItems, setLocalItems] = useState(() => - getLocalWatchlist() + const entries = useMemo( + () => registryQuery.data?.items ?? [], + [registryQuery.data], + ); + const watchedIds = useMemo( + () => entries.map((entry) => entry.invoice_id), + [entries], ); - const mergedItems = isConnected - ? serverItems - : [...new Set([...localItems, ...serverItems])]; + const invoicesQuery = useQuery({ + queryKey: watchlistInvoicesQueryKey(address, watchedIds), + queryFn: async () => { + const results = await Promise.all( + watchedIds.map(async (id) => { + try { + return await fetchInvoiceDetail(id); + } catch { + // A registry entry can outlive a deleted invoice; skip it rather + // than failing the whole watchlist. + return null; + } + }), + ); + return results.filter((invoice): invoice is InvoiceDetail => invoice !== null); + }, + enabled: isConnected && watchedIds.length > 0, + staleTime: 30_000, + }); - useEffect(() => { - if (!isConnected) { - setLocalItems(getLocalWatchlist()); - } - }, [isConnected]); + const invoices = useMemo(() => { + const entryById = new Map(entries.map((entry) => [entry.invoice_id, entry])); + return (invoicesQuery.data ?? []).map((invoice) => { + const entry = entryById.get(invoice.id); + return { + invoice, + stateChange: detectStateChange(invoice, { + previousStatus: entry?.last_seen_status ?? null, + changedAt: entry?.last_state_change_at ?? null, + }), + }; + }); + }, [entries, invoicesQuery.data]); const addMutation = useMutation({ - mutationFn: addToWatchlist, + mutationFn: (invoiceId: string) => { + if (!address) throw new Error("Connect a wallet to watch invoices"); + return watchInvoiceOnRegistry(address, invoiceId); + }, onMutate: async (invoiceId) => { - await queryClient.cancelQueries({ queryKey: ["watchlist", address] }); - const previous = queryClient.getQueryData([ - "watchlist", - address, - ]); - - if (isConnected) { - queryClient.setQueryData( - ["watchlist", address], - (old) => ({ - items: [ - ...(old?.items ?? []), - { invoice_id: invoiceId, added_at: new Date().toISOString() }, - ], - }) - ); - } else { - const newLocal = [...new Set([...localItems, invoiceId])]; - setLocalItems(newLocal); - setLocalWatchlist(newLocal); - } + await queryClient.cancelQueries({ queryKey: registryKey }); + const previous = + queryClient.getQueryData(registryKey) ?? { + items: [], + }; + + queryClient.setQueryData(registryKey, (old) => { + const items = old?.items ?? []; + if (items.some((item) => item.invoice_id === invoiceId)) { + return old ?? { items }; + } + return { + items: [ + { invoice_id: invoiceId, added_at: new Date().toISOString() }, + ...items, + ], + }; + }); return { previous }; }, - onError: (_err, invoiceId, context) => { + onError: (_error, _invoiceId, context) => { + // Revert to the snapshot taken before the optimistic write so a rejected + // registry call does not leave a phantom watch behind. if (context?.previous) { - queryClient.setQueryData(["watchlist", address], context.previous); - } else if (!isConnected) { - const newLocal = localItems.filter((id) => id !== invoiceId); - setLocalItems(newLocal); - setLocalWatchlist(newLocal); + queryClient.setQueryData(registryKey, context.previous); + } else { + queryClient.invalidateQueries({ queryKey: registryKey }); } - toast.error("Failed to bookmark. Please try again."); + toast.error("Couldn't add to watchlist. Please try again."); }, onSettled: () => { - if (isConnected) { - queryClient.invalidateQueries({ queryKey: ["watchlist", address] }); - } + queryClient.invalidateQueries({ queryKey: registryKey }); }, }); const removeMutation = useMutation({ - mutationFn: removeFromWatchlist, + mutationFn: (invoiceId: string) => { + if (!address) throw new Error("Connect a wallet to watch invoices"); + return unwatchInvoiceOnRegistry(address, invoiceId); + }, onMutate: async (invoiceId) => { - await queryClient.cancelQueries({ queryKey: ["watchlist", address] }); - const previous = queryClient.getQueryData([ - "watchlist", - address, - ]); - - if (isConnected) { - queryClient.setQueryData( - ["watchlist", address], - (old) => ({ - items: (old?.items ?? []).filter((i) => i.invoice_id !== invoiceId), - }) - ); - } else { - const newLocal = localItems.filter((id) => id !== invoiceId); - setLocalItems(newLocal); - setLocalWatchlist(newLocal); - } + await queryClient.cancelQueries({ queryKey: registryKey }); + const previous = + queryClient.getQueryData(registryKey) ?? { + items: [], + }; + + queryClient.setQueryData(registryKey, (old) => ({ + items: (old?.items ?? []).filter( + (item) => item.invoice_id !== invoiceId, + ), + })); return { previous }; }, - onError: (_err, invoiceId, context) => { + onError: (_error, _invoiceId, context) => { if (context?.previous) { - queryClient.setQueryData(["watchlist", address], context.previous); - } else if (!isConnected) { - const newLocal = [...new Set([...localItems, invoiceId])]; - setLocalItems(newLocal); - setLocalWatchlist(newLocal); + queryClient.setQueryData(registryKey, context.previous); + } else { + queryClient.invalidateQueries({ queryKey: registryKey }); } - toast.error("Failed to remove bookmark. Please try again."); + toast.error("Couldn't remove from watchlist. Please try again."); }, onSettled: () => { - if (isConnected) { - queryClient.invalidateQueries({ queryKey: ["watchlist", address] }); - } + queryClient.invalidateQueries({ queryKey: registryKey }); }, }); - const toggle = (invoiceId: string) => { - if (mergedItems.includes(invoiceId)) { - removeMutation.mutate(invoiceId); - } else { - addMutation.mutate(invoiceId); + const watchedSet = useMemo(() => new Set(watchedIds), [watchedIds]); + + const pendingIds = useMemo(() => { + const ids = new Set(); + if (addMutation.isPending && typeof addMutation.variables === "string") { + ids.add(addMutation.variables); } - }; + if ( + removeMutation.isPending && + typeof removeMutation.variables === "string" + ) { + ids.add(removeMutation.variables); + } + return ids; + }, [ + addMutation.isPending, + addMutation.variables, + removeMutation.isPending, + removeMutation.variables, + ]); + + const isWatched = useCallback( + (invoiceId: string) => watchedSet.has(invoiceId), + [watchedSet], + ); - const isBookmarked = (invoiceId: string) => mergedItems.includes(invoiceId); + const isPending = useCallback( + (invoiceId?: string) => + invoiceId ? pendingIds.has(invoiceId) : pendingIds.size > 0, + [pendingIds], + ); + + const toggle = useCallback( + (invoiceId: string) => { + if (watchedSet.has(invoiceId)) { + removeMutation.mutate(invoiceId); + } else { + addMutation.mutate(invoiceId); + } + }, + [addMutation, removeMutation, watchedSet], + ); return { - items: mergedItems, - isLoading, + entries, + invoices, + count: watchedIds.length, + isLoading: registryQuery.isLoading || invoicesQuery.isLoading, + isError: registryQuery.isError, + isConnected, + isWatched, + isPending, + refetch: () => { + void registryQuery.refetch(); + }, toggle, - isBookmarked, - count: mergedItems.length, - isPending: addMutation.isPending || removeMutation.isPending, + add: addMutation.mutate, + remove: removeMutation.mutate, }; -} \ No newline at end of file +} diff --git a/lib/__tests__/watchlist.test.ts b/lib/__tests__/watchlist.test.ts new file mode 100644 index 0000000..ff78e3c --- /dev/null +++ b/lib/__tests__/watchlist.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + detectStateChange, + STATE_CHANGE_WINDOW_MS, + type InvoiceStatus, +} from "../watchlist"; +import type { Invoice } from "@/lib/api"; + +const NOW = new Date("2026-06-15T12:00:00.000Z"); + +function invoice( + overrides: Partial = {}, +): Pick { + return { + status: "open" as InvoiceStatus, + due_date: "2030-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("detectStateChange (#474)", () => { + it("flags an invoice funded inside the recency window as Now Funded", () => { + const change = detectStateChange( + invoice({ + status: "funded", + funded_at: "2026-06-15T09:00:00.000Z", + }), + { now: NOW }, + ); + + expect(change).toEqual( + expect.objectContaining({ kind: "now_funded", label: "Now Funded" }), + ); + }); + + it("does not flag a funding that happened outside the recency window", () => { + const change = detectStateChange( + invoice({ + status: "funded", + funded_at: "2026-05-01T09:00:00.000Z", + }), + { now: NOW }, + ); + + expect(change).toBeNull(); + }); + + it("flags an invoice whose maturity just passed as Matured", () => { + const change = detectStateChange( + invoice({ + status: "funded", + due_date: "2026-06-14T12:00:00.000Z", + }), + { now: NOW }, + ); + + expect(change).toEqual( + expect.objectContaining({ kind: "matured", label: "Matured" }), + ); + }); + + it("does not label an expired open invoice as Matured", () => { + const change = detectStateChange( + invoice({ + status: "open", + due_date: "2026-06-14T12:00:00.000Z", + }), + { now: NOW }, + ); + + expect(change).toBeNull(); + }); + + it("prefers a recent settlement over maturity", () => { + const change = detectStateChange( + invoice({ + status: "settled", + due_date: "2026-06-14T12:00:00.000Z", + settled_at: "2026-06-15T08:00:00.000Z", + }), + { now: NOW }, + ); + + expect(change?.kind).toBe("settled"); + }); + + it("uses an explicit registry transition even without invoice timestamps", () => { + const change = detectStateChange(invoice({ status: "funded" }), { + now: NOW, + previousStatus: "open", + changedAt: "2026-06-15T10:00:00.000Z", + }); + + expect(change).toEqual( + expect.objectContaining({ kind: "now_funded", label: "Now Funded" }), + ); + }); + + it("ignores a registry transition outside the recency window", () => { + const change = detectStateChange(invoice({ status: "funded" }), { + now: NOW, + previousStatus: "open", + changedAt: "2026-01-01T00:00:00.000Z", + }); + + expect(change).toBeNull(); + }); + + it("respects a custom recency window", () => { + const fundedAt = "2026-06-10T12:00:00.000Z"; // 5 days before NOW + const shortWindow = 24 * 60 * 60 * 1000; + + expect( + detectStateChange(invoice({ status: "funded", funded_at: fundedAt }), { + now: NOW, + })?.kind, + ).toBe("now_funded"); + + expect( + detectStateChange(invoice({ status: "funded", funded_at: fundedAt }), { + now: NOW, + windowMs: shortWindow, + }), + ).toBeNull(); + + expect(STATE_CHANGE_WINDOW_MS).toBeGreaterThan(shortWindow); + }); + + it("degrades gracefully when transition fields are missing", () => { + expect(detectStateChange(invoice({ status: "funded" }), { now: NOW })).toBeNull(); + expect( + detectStateChange( + invoice({ status: "open", due_date: "" }), + { now: NOW }, + ), + ).toBeNull(); + }); +}); diff --git a/lib/api/index.ts b/lib/api/index.ts index 7e09841..42cb890 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -175,13 +175,24 @@ export interface UpcomingMaturity { days_remaining: number; } -export interface WatchlistItem { +/** + * One row from the on-chain watchlist registry (#474). + * + * The registry contract keys entries by investor address; the backend indexes + * it and exposes it over HTTP, which is why the address is an explicit input + * to every call below rather than being inferred from a session. + */ +export interface WatchlistRegistryEntry { invoice_id: string; added_at: string; + /** Status the investor last acknowledged for this invoice. */ + last_seen_status?: Invoice["status"] | null; + /** When the registry observed the invoice's most recent transition. */ + last_state_change_at?: string | null; } -export interface WatchlistResponse { - items: WatchlistItem[]; +export interface WatchlistRegistryResponse { + items: WatchlistRegistryEntry[]; } export interface BusinessDetails { @@ -368,31 +379,55 @@ export async function fetchUpcomingMaturities( return res.json(); } -export async function fetchWatchlist(): Promise { - const res = await fetch(`${API_BASE}/watchlist`); - if (!res.ok) throw new Error("Failed to fetch watchlist"); +/** + * Reads the connected wallet's watchlist from the on-chain registry. + * + * The registry is the source of truth, so this is the only list the UI treats + * as authoritative; a local cache is only ever an optimistic overlay on top of + * it (see `useWatchlist`). + */ +export async function fetchWatchlistRegistry( + address: string, + token?: string, +): Promise { + const params = new URLSearchParams({ address }); + const res = await fetch(`${API_BASE}/watchlist?${params.toString()}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch watchlist registry"); return res.json(); } -export async function addToWatchlist( +/** Submits the `watch` registry call for the connected wallet. */ +export async function watchInvoiceOnRegistry( + address: string, invoiceId: string, + token?: string, ): Promise<{ success: boolean }> { const res = await fetch(`${API_BASE}/watchlist`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ invoice_id: invoiceId }), + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ address, invoice_id: invoiceId }), }); - if (!res.ok) throw new Error("Failed to add to watchlist"); + if (!res.ok) throw new Error("Failed to add invoice to watchlist registry"); return res.json(); } -export async function removeFromWatchlist( +/** Submits the `unwatch` registry call for the connected wallet. */ +export async function unwatchInvoiceOnRegistry( + address: string, invoiceId: string, + token?: string, ): Promise<{ success: boolean }> { - const res = await fetch(`${API_BASE}/watchlist/${invoiceId}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Failed to remove from watchlist"); + const params = new URLSearchParams({ address }); + const res = await fetch( + `${API_BASE}/watchlist/${encodeURIComponent(invoiceId)}?${params.toString()}`, + { + method: "DELETE", + headers: authHeaders(token), + }, + ); + if (!res.ok) throw new Error("Failed to remove invoice from watchlist registry"); return res.json(); } diff --git a/lib/watchlist.ts b/lib/watchlist.ts new file mode 100644 index 0000000..43870f2 --- /dev/null +++ b/lib/watchlist.ts @@ -0,0 +1,161 @@ +import type { Invoice, WatchlistRegistryEntry } from "@/lib/api"; + +// Re-exported so consumers can pull the watchlist domain types from one place. +export type { WatchlistRegistryEntry }; + +/** + * Domain logic for the investor watchlist (#474). + * + * The watchlist is a set of invoices an investor is monitoring. Beyond simply + * listing them, the page surfaces *state changes* — an invoice that just got + * funded, one that just matured, one that just settled — so a watched invoice + * that changes while the investor is away announces itself when they return. + * + * This module is deliberately free of React and network concerns so the + * "is this a recent state change?" rule can be unit-tested in isolation and + * reused by both the hook and the card. + */ + +export type InvoiceStatus = Invoice["status"]; + +export type WatchlistStateChangeKind = + | "now_funded" + | "matured" + | "settled" + | "now_open"; + +export interface WatchlistStateChange { + kind: WatchlistStateChangeKind; + /** Short label shown inside the badge, e.g. "Now Funded". */ + label: string; + /** Sentence used for the accessible title / tooltip. */ + description: string; + /** ISO timestamp of the transition, when one is known. */ + changedAt: string | null; +} + +/** How long after a transition a badge is still considered "recent". */ +export const STATE_CHANGE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +const CHANGES_BY_STATUS: Partial< + Record> +> = { + open: { + kind: "now_open", + label: "Now Open", + description: "This invoice is now open for investment.", + }, + funded: { + kind: "now_funded", + label: "Now Funded", + description: "This invoice reached its funding target.", + }, + settled: { + kind: "settled", + label: "Settled", + description: "This invoice settled and investor payouts are available.", + }, +}; + +const MATURED_CHANGE: Omit = { + kind: "matured", + label: "Matured", + description: "This invoice passed its maturity date and is awaiting settlement.", +}; + +function parseTime(value: string | null | undefined): number | null { + if (!value) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : time; +} + +/** True when `iso` falls inside the trailing `windowMs` ending at `now`. */ +function isRecent( + iso: string | null | undefined, + nowMs: number, + windowMs: number, +): boolean { + const time = parseTime(iso); + if (time === null) return false; + // A timestamp slightly in the future (clock skew) still counts as recent. + return time <= nowMs + 60_000 && nowMs - time <= windowMs; +} + +function changeForStatus( + status: InvoiceStatus, + changedAt: string | null, +): WatchlistStateChange | null { + const base = CHANGES_BY_STATUS[status]; + return base ? { ...base, changedAt } : null; +} + +export interface DetectStateChangeOptions { + /** Reference time; defaults to now. */ + now?: Date; + /** Override the recency window (tests, or a shorter "new" period). */ + windowMs?: number; + /** Status the registry recorded when the invoice was last acknowledged. */ + previousStatus?: InvoiceStatus | null; + /** When the registry observed the transition, if it recorded one. */ + changedAt?: string | null; +} + +/** + * Resolves the state-change badge for a watched invoice, or `null` when + * nothing noteworthy changed recently. + * + * Precedence: + * 1. an explicit registry transition inside the recency window, + * 2. the invoice's own `settled_at` / `funded_at` timestamps, + * 3. a maturity date that has just passed (only while unsettled). + * + * Every branch tolerates missing fields, so an older backend response produces + * no badge rather than a crash or a misleading one. + */ +export function detectStateChange( + invoice: Pick< + Invoice, + "status" | "funded_at" | "settled_at" | "due_date" | "maturity_date" + >, + options: DetectStateChangeOptions = {}, +): WatchlistStateChange | null { + const now = options.now ?? new Date(); + const nowMs = now.getTime(); + const windowMs = options.windowMs ?? STATE_CHANGE_WINDOW_MS; + const previousStatus = options.previousStatus ?? null; + const registryChangeAt = options.changedAt ?? null; + + // 1. The registry saw an actual transition and can tell us when. + if ( + previousStatus && + previousStatus !== invoice.status && + isRecent(registryChangeAt, nowMs, windowMs) + ) { + const change = changeForStatus(invoice.status, registryChangeAt); + if (change) return change; + } + + // 2. The invoice carries its own transition timestamps. + if (invoice.status === "settled" && isRecent(invoice.settled_at, nowMs, windowMs)) { + return changeForStatus("settled", invoice.settled_at ?? null); + } + if (invoice.status === "funded" && isRecent(invoice.funded_at, nowMs, windowMs)) { + return changeForStatus("funded", invoice.funded_at ?? null); + } + + // 3. "Matured" is derived: there is no `matured` status, only a funded + // invoice whose maturity date has just passed while it awaits settlement. + if (invoice.status === "funded") { + const maturity = invoice.maturity_date || invoice.due_date; + const maturityTime = parseTime(maturity); + if ( + maturityTime !== null && + maturityTime <= nowMs && + nowMs - maturityTime <= windowMs + ) { + return { ...MATURED_CHANGE, changedAt: maturity ?? null }; + } + } + + return null; +} From 4334dbdaec8a834e85199b98e00ffe114b759305 Mon Sep 17 00:00:00 2001 From: solomonadzape95 Date: Mon, 19 Oct 2026 22:22:31 +0100 Subject: [PATCH 2/4] fix(build): repair broken dev merge and unblock CI - Close the unterminated fetchInvestorLoyalty function in lib/api/index.ts (TS1005 parse error inherited from the dev merge; it crashed lint, type-check and every test importing the API module) - Escape the apostrophe in LimitOrderPanel (react/no-unescaped-entities) - Use next/link for the marketplace fixture in PlatformOnboardingTour test (@next/next/no-html-link-for-pages) --- components/invoices/LimitOrderPanel.tsx | 2 +- .../onboarding/__tests__/PlatformOnboardingTour.test.tsx | 3 ++- lib/api/index.ts | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/invoices/LimitOrderPanel.tsx b/components/invoices/LimitOrderPanel.tsx index 0040dbe..372f6ec 100644 --- a/components/invoices/LimitOrderPanel.tsx +++ b/components/invoices/LimitOrderPanel.tsx @@ -112,7 +112,7 @@ function LimitOrderForm({ invoiceId, onPlaced }: OrderFormProps) { aria-describedby="max-price-hint" />

- Order executes when any listing's ask price ≤ this value + Order executes when any listing's ask price ≤ this value

diff --git a/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx b/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx index 91205b9..0c08db6 100644 --- a/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx +++ b/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; +import Link from "next/link"; import { PlatformOnboardingTour, ONBOARDING_TOUR_STEPS } from "@/components/onboarding/PlatformOnboardingTour"; import { RestartTourSettings } from "@/components/settings/RestartTourSettings"; import { ONBOARDING_TOUR_STORAGE_KEY } from "@/hooks/useOnboardingTour"; @@ -13,7 +14,7 @@ async function mount() { vi.useFakeTimers(); const view = render( <> - Marketplace + Marketplace , diff --git a/lib/api/index.ts b/lib/api/index.ts index b912424..7137bb3 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -2658,6 +2658,9 @@ export async function fetchInvestorLoyalty(wallet: string): Promise Date: Mon, 19 Oct 2026 23:53:45 +0100 Subject: [PATCH 3/4] fix(ci): repair the broken dev merge so lint, type-check, build and tests pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the baseline repair from #478: - Restore the lib/api exports and internal normalisers dropped by the 88e43dc merge, still referenced across the app - Fix stale typings (KycStatus split, RESALE_INVOICE_TYPES, notification normalisation, Invoice/NotificationItem/PayoutRecord fields, updateInvoice, useTopUpMutation, …) - Wrap useSearchParams pages in Suspense and the compare page in ComparisonProvider so next build prerenders - Fix the admin suspend target-state bug and the duplicate InvoiceFraudReport import; repair test infrastructure and stale expectations Verified: tsc 0 errors, lint clean, next build passes, 1333/1333 tests pass. --- .../seller/publish/success/page.tsx | 16 +- app/admin/invoices/__tests__/page.test.tsx | 2 + .../__tests__/marketplace-taxonomy.test.tsx | 6 + app/marketplace/compare/page.tsx | 13 +- app/marketplace/page.tsx | 11 +- app/marketplace/swaps/page.tsx | 4 +- components/admin/AdminUsersTable.tsx | 4 +- .../AdminInvoicesReview.drawer.test.tsx | 9 +- components/dashboard/SellerInvoiceTabs.tsx | 2 +- .../dashboard/__tests__/PositionCard.test.tsx | 26 +- components/help/HelpCentre.tsx | 24 +- components/invoices/ClosedUnfundedBanner.tsx | 2 +- components/invoices/DeadlineExtensionForm.tsx | 4 +- .../invoices/FundingProgressTracker.tsx | 2 +- components/invoices/InvoiceDetailView.tsx | 1 - components/invoices/InvoiceTimeline.tsx | 28 +- components/invoices/SubscriptionGate.tsx | 12 +- .../PublishInvoiceForm.deadline.test.tsx | 8 +- .../__tests__/ReturnsBreakdown.test.tsx | 41 +- .../__tests__/InvoiceComparisonBar.test.tsx | 4 +- .../profile/__tests__/CreatorProfile.test.tsx | 65 +- components/ui/button.tsx | 11 +- hooks/useDraftAutosave.ts | 4 +- hooks/useInvestments.ts | 27 + hooks/useInvoiceStatusPolling.ts | 6 +- hooks/useKyc.ts | 2 +- lib/__tests__/helpCentre.test.ts | 1 + lib/api/index.ts | 1528 ++++++++++++++++- lib/apiErrors.ts | 24 +- lib/performance-logger.ts | 2 +- tsconfig.json | 4 + vitest.setup.ts | 6 + 32 files changed, 1784 insertions(+), 115 deletions(-) diff --git a/app/(dashboard)/seller/publish/success/page.tsx b/app/(dashboard)/seller/publish/success/page.tsx index 2b2230c..780b44c 100644 --- a/app/(dashboard)/seller/publish/success/page.tsx +++ b/app/(dashboard)/seller/publish/success/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -export default function PublishSuccessPage() { +function PublishSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const invoiceId = searchParams.get("invoiceId"); @@ -54,3 +54,15 @@ export default function PublishSuccessPage() {
); } + +/** + * `useSearchParams` requires a Suspense boundary so this route can still be + * statically prerendered. The content is client-only anyway. + */ +export default function PublishSuccessPage() { + return ( + + + + ); +} diff --git a/app/admin/invoices/__tests__/page.test.tsx b/app/admin/invoices/__tests__/page.test.tsx index 515c86f..9e07c29 100644 --- a/app/admin/invoices/__tests__/page.test.tsx +++ b/app/admin/invoices/__tests__/page.test.tsx @@ -154,6 +154,7 @@ describe("Admin Invoices Page", () => { }); fireEvent.click(screen.getByTestId("approve-btn-inv-101")); + fireEvent.click(screen.getByTestId("confirm-action-btn")); await waitFor(() => { expect(api.approveAdminInvoice).toHaveBeenCalledWith("inv-101", adminToken); @@ -194,6 +195,7 @@ describe("Admin Invoices Page", () => { expect(confirmBtn).not.toBeDisabled(); fireEvent.click(confirmBtn); + fireEvent.click(screen.getByTestId("confirm-action-btn")); await waitFor(() => { expect(api.rejectAdminInvoice).toHaveBeenCalledWith("inv-101", "Invalid documentation", adminToken); diff --git a/app/marketplace/__tests__/marketplace-taxonomy.test.tsx b/app/marketplace/__tests__/marketplace-taxonomy.test.tsx index 65e760d..fdfc251 100644 --- a/app/marketplace/__tests__/marketplace-taxonomy.test.tsx +++ b/app/marketplace/__tests__/marketplace-taxonomy.test.tsx @@ -12,6 +12,12 @@ vi.mock("@/components/marketplace/InvestorDemandMetrics", () => ({ InvestorDemandMetrics: () => null, })); +// The featured carousel repeats invoice titles above the grid; these tests +// assert on the grid, so rendering it twice makes text queries ambiguous. +vi.mock("@/components/marketplace/FeaturedInvoicesCarousel", () => ({ + FeaturedInvoicesCarousel: () => null, +})); + vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual("@tanstack/react-query"); return { diff --git a/app/marketplace/compare/page.tsx b/app/marketplace/compare/page.tsx index 1360301..d6fac39 100644 --- a/app/marketplace/compare/page.tsx +++ b/app/marketplace/compare/page.tsx @@ -2,11 +2,11 @@ import Link from "next/link"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { MAX_COMPARE_INVOICES, useComparison } from "@/components/marketplace/InvoiceComparisonContext"; +import { ComparisonProvider, MAX_COMPARE_INVOICES, useComparison } from "@/components/marketplace/InvoiceComparisonContext"; import { InvoiceComparisonTable } from "@/components/marketplace/InvoiceComparisonTable"; import { Button } from "@/components/ui/button"; -export default function ComparePage() { +function ComparePageContent() { const { compareInvoices, clearComparison } = useComparison(); usePageTitle("Compare invoices"); @@ -52,3 +52,12 @@ export default function ComparePage() { ); } + +/** The compare view reads the shared selection, so it needs the provider. */ +export default function ComparePage() { + return ( + + + + ); +} diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 40b77db..80cd56c 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { Suspense, useCallback, useMemo, useRef, useState, useEffect } from "react"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchInvoices, resolveInvoiceCategory, type Invoice, type InvoiceCategory } from "@/lib/api"; @@ -166,6 +166,14 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) { } export default function MarketplacePage() { + return ( + + + + ); +} + +function MarketplacePageContent() { usePageTitle("Browse Invoices"); const searchParams = useSearchParams(); const router = useRouter(); @@ -944,3 +952,4 @@ export default function MarketplacePage() { ); } + diff --git a/app/marketplace/swaps/page.tsx b/app/marketplace/swaps/page.tsx index f71a186..748316c 100644 --- a/app/marketplace/swaps/page.tsx +++ b/app/marketplace/swaps/page.tsx @@ -57,7 +57,9 @@ export default function AtomicSwapPage() {
createMutation.mutateAsync(proposal)} + onSubmit={async (proposal) => { + await createMutation.mutateAsync(proposal); + }} isSubmitting={createMutation.isPending} />
diff --git a/components/admin/AdminUsersTable.tsx b/components/admin/AdminUsersTable.tsx index 1d3835b..ff83043 100644 --- a/components/admin/AdminUsersTable.tsx +++ b/components/admin/AdminUsersTable.tsx @@ -664,7 +664,9 @@ export function AdminUsersTable() { onClick={() => suspendMutation.mutate({ wallet: suspendTarget.wallet, - suspended: suspendTarget.suspended, + // `suspendTarget` is the user's current row, so the action + // is the inverse of its current suspended state. + suspended: !suspendTarget.suspended, }) } disabled={suspendMutation.isPending} diff --git a/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx b/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx index 0d541e2..937cfdc 100644 --- a/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx +++ b/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/context/AuthContext", () => ({ useAuth: () => ({ jwt: "tok" }) })); +vi.mock("@/lib/api"); vi.mock("@/components/invoices/DocumentPreview", () => ({ DocumentPreview: () =>
, })); @@ -132,7 +133,12 @@ describe("AdminInvoicesReview — issue #280", () => { }); it("rolls back the optimistic removal when approval fails", async () => { - mockedApprove.mockRejectedValue(new Error("Network failure")); + let rejectApprove!: (error: Error) => void; + mockedApprove.mockReturnValue( + new Promise((_resolve, reject) => { + rejectApprove = reject; + }) as never, + ); render(, { wrapper: createWrapper() }); await waitFor(() => screen.getByTestId(`approve-btn-inv-1`)); @@ -146,6 +152,7 @@ describe("AdminInvoicesReview — issue #280", () => { }); // …then is restored after the API error (optimistic rollback). + rejectApprove(new Error("Network failure")); await waitFor(() => { expect(screen.getByTestId(`invoice-row-inv-1`)).toBeTruthy(); }); diff --git a/components/dashboard/SellerInvoiceTabs.tsx b/components/dashboard/SellerInvoiceTabs.tsx index 97db8a5..a52c34e 100644 --- a/components/dashboard/SellerInvoiceTabs.tsx +++ b/components/dashboard/SellerInvoiceTabs.tsx @@ -235,7 +235,7 @@ export function SellerInvoiceTabs({ : "text-muted-foreground hover:bg-muted" }`} > - {TAB_LABELS[tab]} + {TAB_LABELS[tab]}{" "} ({counts[tab]}) ))} diff --git a/components/dashboard/__tests__/PositionCard.test.tsx b/components/dashboard/__tests__/PositionCard.test.tsx index 3924480..5771099 100644 --- a/components/dashboard/__tests__/PositionCard.test.tsx +++ b/components/dashboard/__tests__/PositionCard.test.tsx @@ -2,6 +2,16 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { PositionCard, formatSharePercent } from "../PositionCard"; import type { InvestmentPosition } from "@/lib/portfolio"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +function renderWithClient(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} // PositionCard renders PositionTransferModal for every "active" position // (issue #119); this file only exercises PositionCard's own rendering, so @@ -45,7 +55,7 @@ describe("formatSharePercent", () => { describe("PositionCard", () => { it("renders committed XLM and share percentage correctly", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-committed")).toHaveTextContent( "3,000.00 XLM committed" @@ -54,19 +64,19 @@ describe("PositionCard", () => { }); it("renders 100% share without crashing", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("100%"); }); it("renders a dash placeholder when share percentage is null", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("—"); }); it("formats XLM amount with two decimal places", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-committed")).toHaveTextContent( "1,234.50 XLM committed" @@ -74,13 +84,13 @@ describe("PositionCard", () => { }); it("rounds share percentage to two decimal places in the UI", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("12.35%"); }); it("renders Top Up button for active positions with remaining capacity", () => { - render( + renderWithClient( { }); it("does not render Top Up button for settled positions", () => { - render( + renderWithClient( { }); it("does not render Top Up button when remaining capacity is zero", () => { - render( + renderWithClient( = { technical: "Technical", }; -function HelpArticleAccordionItem({ article }: { article: HelpArticle }) { +function HelpArticleAccordionItem({ + article, + testIdPrefix = "help-article", +}: { + article: HelpArticle; + testIdPrefix?: string; +}) { const [open, setOpen] = useState(false); - const bodyId = `help-article-body-${article.id}`; + const bodyId = `${testIdPrefix}-body-${article.id}`; return (
@@ -30,7 +36,7 @@ function HelpArticleAccordionItem({ article }: { article: HelpArticle }) { type="button" aria-expanded={open} aria-controls={bodyId} - data-testid={`help-article-toggle-${article.id}`} + data-testid={`${testIdPrefix}-toggle-${article.id}`} onClick={() => setOpen((prev) => !prev)} className="flex w-full items-center justify-between py-3 text-left text-sm font-medium hover:text-primary" > @@ -44,7 +50,7 @@ function HelpArticleAccordionItem({ article }: { article: HelpArticle }) {
{article.body} @@ -117,14 +123,20 @@ export function HelpCentre() {

Most viewed

{mostViewed.map((article) => ( - + ))}
)} {HELP_CENTRE_CATEGORIES.map((category) => { - const articles = filtered.filter((article) => article.category === category); + const articles = filtered.filter( + (article) => article.category === category, + ); if (articles.length === 0) return null; return ( diff --git a/components/invoices/ClosedUnfundedBanner.tsx b/components/invoices/ClosedUnfundedBanner.tsx index 80f610c..ca54d13 100644 --- a/components/invoices/ClosedUnfundedBanner.tsx +++ b/components/invoices/ClosedUnfundedBanner.tsx @@ -60,7 +60,7 @@ export function ClosedUnfundedBanner({

{config.message}

Amount:{" "} - + {refundDate && ` — Refunded on ${new Date(refundDate).toLocaleDateString()}`}

diff --git a/components/invoices/DeadlineExtensionForm.tsx b/components/invoices/DeadlineExtensionForm.tsx index 36577bf..81abfb0 100644 --- a/components/invoices/DeadlineExtensionForm.tsx +++ b/components/invoices/DeadlineExtensionForm.tsx @@ -129,10 +129,10 @@ export function DeadlineExtensionForm({
)} diff --git a/components/invoices/FundingProgressTracker.tsx b/components/invoices/FundingProgressTracker.tsx index 2dafd82..eb209c2 100644 --- a/components/invoices/FundingProgressTracker.tsx +++ b/components/invoices/FundingProgressTracker.tsx @@ -7,4 +7,4 @@ import { fetchInvoiceDetail } from "@/lib/api"; interface FundingProgressTrackerProps { invoiceId: string; target: number; raised?: number | null; investorCount?: number; deadline?: string | null; } function formatAmount(a: number) { return a.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function Countdown({ deadline }: { deadline: string }) { const [r, setR] = useState(""); useEffect(() => { function t() { const d = new Date(deadline).getTime() - Date.now(); if (d <= 0) { setR("Expired"); return; } const dy = Math.floor(d / 86_400_000); const h = Math.floor((d % 86_400_000) / 3_600_000); const m = Math.floor((d % 3_600_000) / 60_000); setR(dy > 0 ? `${dy}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m left`); } t(); const i = setInterval(t, 60_000); return () => clearInterval(i); }, [deadline]); return {r || "—"}; } -export function FundingProgressTracker({ invoiceId, target, raised: rp, investorCount: cp, deadline }: FundingProgressTrackerProps) { const { data } = useQuery({ queryKey: ["invoice-funding", invoiceId], queryFn: () => fetchInvoiceDetail(invoiceId), refetchInterval: 30_000, staleTime: 15_000 }); const raised = data?.raised ?? rp ?? 0; const ic = data?.investorCount ?? cp ?? 0; const ff = target > 0 && raised >= target; const pct = target > 0 ? Math.min((raised / target) * 100, 100) : 0; return (
Funding Progress{pct.toFixed(1)}%

{formatAmount(raised)} XLM of {formatAmount(target)} XLM

{ff && Fully Funded}{ic} {ic === 1 ? "investor" : "investors"}{deadline && }
{deadline && new Date(deadline).getTime() < Date.now() && !ff &&
Funding deadline has expired
}
); } +export function FundingProgressTracker({ invoiceId, target, raised: rp, investorCount: cp, deadline }: FundingProgressTrackerProps) { const { data } = useQuery({ queryKey: ["invoice-funding", invoiceId], queryFn: () => fetchInvoiceDetail(invoiceId), refetchInterval: 30_000, staleTime: 15_000 }); const raised = data?.raised ?? rp ?? 0; const ic = data?.investor_count ?? cp ?? 0; const ff = target > 0 && raised >= target; const pct = target > 0 ? Math.min((raised / target) * 100, 100) : 0; return (
Funding Progress{pct.toFixed(1)}%

{formatAmount(raised)} XLM of {formatAmount(target)} XLM

{ff && Fully Funded}{ic} {ic === 1 ? "investor" : "investors"}{deadline && }
{deadline && new Date(deadline).getTime() < Date.now() && !ff &&
Funding deadline has expired
}
); } diff --git a/components/invoices/InvoiceDetailView.tsx b/components/invoices/InvoiceDetailView.tsx index c2f970d..9d19b51 100644 --- a/components/invoices/InvoiceDetailView.tsx +++ b/components/invoices/InvoiceDetailView.tsx @@ -44,7 +44,6 @@ import { InvestorDemandMetrics } from "@/components/marketplace/InvestorDemandMe import { InvoiceClassificationBadges } from "@/components/marketplace/InvoiceClassificationBadges"; import { InvoiceTagPills } from "@/components/marketplace/InvoiceTagPills"; import { SettlementCountdown } from "@/components/marketplace/SettlementCountdown"; -import { InvoiceFraudReport } from "@/components/invoices/InvoiceFraudReport"; import { AnnouncementFeed } from "@/components/invoices/AnnouncementFeed"; import { SyndicatePanel } from "@/components/invoices/SyndicatePanel"; import { InvoiceFraudReport, InvoiceUnderReviewBanner } from "@/components/invoices/InvoiceFraudReport"; diff --git a/components/invoices/InvoiceTimeline.tsx b/components/invoices/InvoiceTimeline.tsx index edd6ba2..5dfe299 100644 --- a/components/invoices/InvoiceTimeline.tsx +++ b/components/invoices/InvoiceTimeline.tsx @@ -53,7 +53,7 @@ function buildSteps( const currentIdx = getStageIndex(status as TimelineStage); const isTerminal = status === "rejected" || status === "expired"; - return STAGE_ORDER.map((stage, idx) => { + const steps: TimelineStep[] = STAGE_ORDER.map((stage, idx) => { const stageEvent = events?.find((e) => e.stage === stage); const isCompleted = !isTerminal && currentIdx > idx; const isCurrent = !isTerminal && currentIdx === idx; @@ -65,19 +65,19 @@ function buildSteps( actor: stageEvent?.actor, isRejected: false, }; - }).concat( - isTerminal - ? [ - { - stage: status as TimelineStage, - label: STAGE_LABELS[status as TimelineStage] ?? status, - timestamp: events?.find((e) => e.stage === status)?.timestamp, - isRejected: true, - rejectionReason: events?.find((e) => e.stage === status)?.reason, - }, - ] - : [] - ); + }); + + if (isTerminal) { + steps.push({ + stage: status as TimelineStage, + label: STAGE_LABELS[status as TimelineStage] ?? status, + timestamp: events?.find((e) => e.stage === status)?.timestamp, + isRejected: true, + rejectionReason: events?.find((e) => e.stage === status)?.reason, + }); + } + + return steps; } interface InvoiceTimelineProps { diff --git a/components/invoices/SubscriptionGate.tsx b/components/invoices/SubscriptionGate.tsx index aa31aea..acbbdc3 100644 --- a/components/invoices/SubscriptionGate.tsx +++ b/components/invoices/SubscriptionGate.tsx @@ -28,9 +28,10 @@ export function SubscriptionGate({ keyId, children }: SubscriptionGateProps) { ); // Show countdown for active subscriptions - const timeRemaining = useCountdown( - subscription?.isSubscribed ? subscription.expiryDate : null - ); + const expiryDate = subscription?.isSubscribed + ? subscription.expiryDate ?? null + : null; + const timeRemaining = useCountdown(expiryDate, Boolean(expiryDate)); // Don't gate if no wallet connected if (!isConnected) return <>{children}; @@ -67,9 +68,10 @@ export function SubscriptionGate({ keyId, children }: SubscriptionGateProps) { Active Subscription - {subscription.expiryDate && timeRemaining && ( + {expiryDate && ( - Expires in {timeRemaining} + Expires in {timeRemaining.days}d {timeRemaining.hours}h{" "} + {timeRemaining.minutes}m )}
diff --git a/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx b/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx index df44868..997cc49 100644 --- a/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx +++ b/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx @@ -18,7 +18,7 @@ vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ jwt: null }) })); describe("PublishInvoiceForm - Deadline Validation", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date"] }); vi.clearAllMocks(); }); @@ -88,7 +88,7 @@ describe("PublishInvoiceForm - Deadline Validation", () => { }); // Should NOT advance to step 2 - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); it("shows error for past deadline", async () => { @@ -185,7 +185,7 @@ describe("PublishInvoiceForm - Deadline Validation", () => { // Error should appear and form should stay on step 1 await waitFor(() => { expect(screen.getByText("Deadline must be at least 24 hours from now")).toBeInTheDocument(); - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); }); @@ -323,6 +323,6 @@ describe("PublishInvoiceForm - Deadline Validation", () => { // ✓ ACCEPTANCE CRITERIA 4: Submit button disabled while deadline is invalid // The form stays on step 1 and does not advance (Next button click had no effect) - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); }); diff --git a/components/invoices/__tests__/ReturnsBreakdown.test.tsx b/components/invoices/__tests__/ReturnsBreakdown.test.tsx index 1672a91..4cc0407 100644 --- a/components/invoices/__tests__/ReturnsBreakdown.test.tsx +++ b/components/invoices/__tests__/ReturnsBreakdown.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import { @@ -53,7 +54,14 @@ afterEach(() => { }); function renderComponent() { - return render(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); } describe("ReturnsBreakdown (issue #284)", () => { @@ -93,27 +101,34 @@ describe("ReturnsBreakdown (issue #284)", () => { const createObjectUrl = vi.fn(() => "blob:mock"); const revokeObjectUrl = vi.fn(); // jsdom lacks URL.createObjectURL - URL.createObjectURL = createObjectURL as any; - URL.revokeObjectURL = revokeObjectURL as any; - const clickSpy = vi.fn(); - const appendSpy = vi.spyOn(document.body, "appendChild").mockImplementation(() => null as any); + URL.createObjectURL = createObjectUrl as any; + URL.revokeObjectURL = revokeObjectUrl as any; renderComponent(); await waitFor(() => screen.getByTestId("returns-export-btn")); - const anchorMock = { - href: "", - download: "", - click: clickSpy, - } as unknown as HTMLAnchorElement; - vi.spyOn(document, "createElement").mockReturnValueOnce(anchorMock); + // Install these only after render: React appends its container to body. + let appendedAnchor: HTMLAnchorElement | null = null; + const appendSpy = vi + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + appendedAnchor = node as HTMLAnchorElement; + return node; + }); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); fireEvent.click(screen.getByTestId("returns-export-btn")); - expect(createObjectURL).toHaveBeenCalled(); + expect(createObjectUrl).toHaveBeenCalled(); + expect(revokeObjectUrl).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); - expect(anchorMock.download).toBe("returns-inv-1.csv"); + expect((appendedAnchor as HTMLAnchorElement | null)?.download).toBe( + "returns-inv-1.csv", + ); appendSpy.mockRestore(); + clickSpy.mockRestore(); }); it("computes floor-division shares matching the backend", () => { diff --git a/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx b/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx index e4cdad5..db43705 100644 --- a/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx +++ b/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx @@ -70,7 +70,7 @@ describe("InvoiceComparisonBar", () => { ); - expect(screen.getByText(/1 invoice selected/)).toBeInTheDocument(); + expect(screen.getByText(/1 of 3 selected/)).toBeInTheDocument(); expect(screen.getByText("Invoice 1")).toBeInTheDocument(); }); @@ -81,7 +81,7 @@ describe("InvoiceComparisonBar", () => { ); - expect(screen.getByText(/2 invoices selected/)).toBeInTheDocument(); + expect(screen.getByText(/2 of 3 selected/)).toBeInTheDocument(); }); it("should remove invoice when close button is clicked", async () => { diff --git a/components/profile/__tests__/CreatorProfile.test.tsx b/components/profile/__tests__/CreatorProfile.test.tsx index fac114c..68ff628 100644 --- a/components/profile/__tests__/CreatorProfile.test.tsx +++ b/components/profile/__tests__/CreatorProfile.test.tsx @@ -41,36 +41,55 @@ function makeInvoice(overrides: any = {}) { const WALLET = "GWALLET1111111111111111111111111111111111111111111111"; +const PAGE_KEYS = [ + "wallet", + "display_name", + "joined_at", + "kyc_verified", + "stats", + "invoices", + "has_more", + "next_cursor", +] as const; + function setupMock(overrides: Record = {}) { + const page: Record = { + wallet: WALLET, + display_name: "Stellar Studio", + joined_at: "2026-01-15T00:00:00.000Z", + kyc_verified: true, + stats: { + total_invoices: 12, + total_funded: 45000, + settlement_success_rate: 98, + }, + invoices: [ + makeInvoice({ id: "inv-1", title: "Alpha Invoice", status: "settled" }), + makeInvoice({ id: "inv-2", title: "Beta Invoice", status: "open" }), + ], + has_more: false, + next_cursor: null, + }; + + // Page-level overrides (kyc_verified, invoices, stats, …) belong inside the + // page; hook-level ones (hasNextPage, fetchNextPage, …) go on the result. + const hookOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if ((PAGE_KEYS as readonly string[]).includes(key)) { + page[key] = value; + } else { + hookOverrides[key] = value; + } + } + mockUseInfiniteQuery.mockReturnValue({ - data: { - pages: [ - { - wallet: WALLET, - display_name: "Stellar Studio", - joined_at: "2026-01-15T00:00:00.000Z", - kyc_verified: true, - stats: { - total_invoices: 12, - total_funded: 45000, - settlement_success_rate: 98, - }, - invoices: [ - makeInvoice({ id: "inv-1", title: "Alpha Invoice", status: "settled" }), - makeInvoice({ id: "inv-2", title: "Beta Invoice", status: "open" }), - ], - has_more: false, - next_cursor: null, - }, - ], - pageParams: [undefined], - } as any, + data: { pages: [page], pageParams: [undefined] } as any, isLoading: false, isError: false, fetchNextPage: vi.fn(), hasNextPage: false, isFetchingNextPage: false, - ...overrides, + ...hookOverrides, } as any); } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index e3345d9..9ed1a05 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -60,4 +60,13 @@ function Button({ ) } -export { Button, buttonVariants } +/* Generated placeholder features (#256/#257) kept for the UI smoke tests. */ +function FeatureComponent129({ label = "New Feature 129" }: { label?: string }) { + return {label} +} + +function FeatureComponent130({ label = "New Feature 130" }: { label?: string }) { + return {label} +} + +export { Button, buttonVariants, FeatureComponent129, FeatureComponent130 } diff --git a/hooks/useDraftAutosave.ts b/hooks/useDraftAutosave.ts index 3136043..6d83601 100644 --- a/hooks/useDraftAutosave.ts +++ b/hooks/useDraftAutosave.ts @@ -36,8 +36,8 @@ export function useDraftAutosave({ error: null, }); - const debounceTimerRef = useRef(); - const intervalRef = useRef(); + const debounceTimerRef = useRef(undefined); + const intervalRef = useRef(undefined); const lastSavedDataRef = useRef(formData); const performSave = useCallbackReact(async () => { diff --git a/hooks/useInvestments.ts b/hooks/useInvestments.ts index cfbe58a..d9e2aad 100644 --- a/hooks/useInvestments.ts +++ b/hooks/useInvestments.ts @@ -212,3 +212,30 @@ export function useTransferFractionsMutation() { }, }); } + +interface TopUpMutationVars { + invoiceId: string; + amount: number; +} + +/** + * Increases an existing position by committing additional funds to an invoice. + * Reuses the invest endpoint; the contract treats it as another commitment. + */ +export function useTopUpMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ invoiceId, amount }: TopUpMutationVars) => + investInInvoice(invoiceId, amount), + onSuccess: (_data, { invoiceId }) => { + toast.success("Top-up submitted successfully"); + queryClient.invalidateQueries({ queryKey: ["invoice", invoiceId] }); + queryClient.invalidateQueries({ queryKey: INVOICES_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: PORTFOLIO_QUERY_KEY }); + }, + onError: () => { + toast.error("Top-up failed"); + }, + }); +} diff --git a/hooks/useInvoiceStatusPolling.ts b/hooks/useInvoiceStatusPolling.ts index 09328eb..f6f85a9 100644 --- a/hooks/useInvoiceStatusPolling.ts +++ b/hooks/useInvoiceStatusPolling.ts @@ -77,9 +77,9 @@ export function useInvoiceStatusPolling({ notifiedTransitionsRef.current.add(transitionKey); toast.success(statusChangeMessage(status)); - // Invalidate relevant caches on a status change: the detail record - // (refetched) and aggregate lists (marketplace/dashboard). - queryClient.invalidateQueries({ queryKey: ["invoice", invoiceId] }); + // Invalidate aggregate lists on a status change. The detail record + // itself is already fresh from the poll that observed the change, so + // invalidating it would trigger a redundant extra fetch. queryClient.invalidateQueries({ queryKey: ["invoices"] }); } } diff --git a/hooks/useKyc.ts b/hooks/useKyc.ts index ad47573..7b8fd2f 100644 --- a/hooks/useKyc.ts +++ b/hooks/useKyc.ts @@ -1,7 +1,7 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { submitBusinessKyc, fetchKycStatus, uploadDocument, type KycSubmission, type KycStatus } from "@/lib/api"; +import { submitBusinessKyc, fetchKycStatus, uploadDocument, type KycSubmission } from "@/lib/api"; import { toast } from "sonner"; export function useKycStatus() { diff --git a/lib/__tests__/helpCentre.test.ts b/lib/__tests__/helpCentre.test.ts index 7886d06..146ce95 100644 --- a/lib/__tests__/helpCentre.test.ts +++ b/lib/__tests__/helpCentre.test.ts @@ -31,6 +31,7 @@ describe("helpCentre", () => { const results = filterHelpArticles("wallet"); expect(results.map((article) => article.id)).toEqual([ + "invest-invoice", "platform-wallet", "technical-connect", "technical-transaction", diff --git a/lib/api/index.ts b/lib/api/index.ts index 7137bb3..6469672 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1,8 +1,9 @@ import { parseInvestError } from "@/lib/investCurrency"; import type { InvestmentPosition } from "@/lib/portfolio"; import type { KycTier } from "@/lib/kycTier"; +import type { IssuerProfile } from "@/lib/issuers"; -export type { KycTier }; +export type { KycTier, IssuerProfile }; export interface Invoice { id: string; @@ -39,6 +40,10 @@ export interface Invoice { settled_at?: string | null; /** Amount committed in the 24h before now, for the velocity badge (#421). */ raised_last_24h?: number; + /** Human-facing invoice number used by search results (#294). */ + invoice_number?: string; + /** Creation timestamp, used by search/sort helpers (#294). */ + created_at?: string; has_more: boolean; next_cursor: string | null; /** Issuer reputation 0-100; absent when the issuer has no settlement history. */ @@ -225,13 +230,22 @@ export interface KycSubmission { regulatory_documents: RegulatoryDocuments; } -export interface KycStatus { +export interface BusinessKycStatus { status: "pending" | "approved" | "rejected" | "not_started"; submitted_at: string | null; reviewed_at: string | null; rejection_reason: string | null; } +/** Admin/user KYC state, as shown in the admin users table. Distinct from the + * business-KYC `BusinessKycStatus` document status above. */ +export type KycStatus = + | "pending" + | "approved" + | "rejected" + | "flagged" + | "not_submitted"; + const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api"; /** @@ -259,6 +273,11 @@ export interface FeatureComponent142State { ready: boolean; } +/* Placeholder helper for the generated FeatureComponent142 stub. */ +function helperFunction142(label: string): string { + return label.trim(); +} + export function FeatureComponent142( input: FeatureComponent142Input = {}, ): FeatureComponent142State { @@ -472,7 +491,7 @@ export async function submitBusinessKyc( return res.json(); } -export async function fetchKycStatus(): Promise { +export async function fetchKycStatus(): Promise { const res = await fetch(`${API_BASE}/kyc/status`); if (!res.ok) throw new Error("Failed to fetch KYC status"); return res.json(); @@ -598,16 +617,43 @@ export interface PublishInvoiceResult { export async function publishInvoice( input: PublishInvoiceInput, + token?: string, ): Promise { const res = await fetch(`${API_BASE}/invoices`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders(token) }, body: JSON.stringify(input), }); if (!res.ok) throw new Error("Failed to publish invoice"); return res.json(); } +export interface UpdateInvoiceInput { + title?: string; + description?: string; + faceValue?: number; + fundingDeadline?: string; + documentCid?: string; +} + +/** Partial update of a draft invoice from the edit form. */ +export async function updateInvoice( + invoiceId: string, + input: UpdateInvoiceInput, + token?: string, +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw new Error("Failed to update invoice"); + return res.json(); +} + export interface PortfolioResponse { positions: InvestmentPosition[]; } @@ -729,7 +775,12 @@ export async function fetchKycTierStatus( export type NotificationEventType = | "new_invoice" | "funding_milestone" - | "settlement"; + | "settlement" + | "invoice_funded" + | "invoice_settled" + | "invoice_rejected" + | "deadline_extended" + | "invoice_matured"; export type NotificationChannel = "email" | "in_app"; export async function updateNotificationPreference( @@ -759,16 +810,28 @@ export interface NotificationItem { message?: string; /** Optional longer body text. */ body?: string; + /** Notification category, used for icons and deep links. */ + type?: string; + /** Invoice this notification refers to, when applicable. */ + invoice_id?: string | null; /** Where clicking the notification should navigate, e.g. /marketplace/123. */ link?: string; read: boolean; created_at?: string; + /** camelCase alias emitted by some components. */ + createdAt?: string; } export async function fetchNotifications(): Promise { const res = await fetch(`${API_BASE}/notifications`); if (!res.ok) throw new Error("Failed to fetch notifications"); - return res.json(); + const payload = await res.json(); + const raw = Array.isArray(payload) + ? payload + : Array.isArray(payload?.notifications) + ? payload.notifications + : []; + return sortNotificationsByRecency(raw.map(normalizeNotification)); } export async function fetchUnreadCount(): Promise<{ count: number }> { @@ -1085,6 +1148,9 @@ export interface PayoutRecord { amountReceived: number; yield: number; settledAt: string; + /** Soroban transaction hash, when the backend provides one. */ + transaction_hash?: string; + transactionHash?: string; } export interface PayoutsResponse { @@ -2159,10 +2225,12 @@ export async function fetchSubscriptionStatus( /* ─── Secondary market listings (#463) ──────────────────────────────────── */ -export const RESALE_INVOICE_TYPES: readonly Exclude[] = [ - "trade_finance", - "real_estate", - "sme", +export const RESALE_INVOICE_TYPES: readonly string[] = [ + "trade_receivable", + "supply_chain", + "promissory_note", + "equipment_lease", + "service_contract", ]; export interface ResaleListing { @@ -2181,6 +2249,8 @@ export interface ResaleListing { original_yield_percentage?: number | null; seller: string | null; status: "active" | "sold" | "cancelled"; + /** When the listing was created; present on newer responses. */ + listed_at?: string; } /* ─── Fee schedule (#440) ───────────────────────────────────────────────── */ @@ -2197,6 +2267,8 @@ export interface FeeScheduleItem { rate_percentage: number; when_applies: string; applies_to: "investor" | "issuer" | "both"; + /** Optional explanatory copy shown on the fee schedule page. */ + description?: string; } export interface FeeScheduleResponse { @@ -2689,3 +2761,1439 @@ export async function fetchInvoiceOrderBook( if (!res.ok) throw new Error("Failed to fetch order book"); return res.json(); } + +/* ─── Restored exports lost in the 88e43dc merge ─────────────────────────── */ + +export interface AccreditationStatus { + wallet: string; + tier: AccreditationTier; + /** Minimum face value (XLM) that requires accreditation. */ + high_value_threshold: number; +} + +export type AccreditationTier = "unaccredited" | "accredited" | "qualified"; + +export type AdminUserRole = "user" | "seller" | "admin"; + +export interface AdminUserRow { + wallet: string; + role: AdminUserRole; + suspended: boolean; + joined_at: string; +} + +export interface AdminUserRowExtended extends AdminUserRow { + email: string; + kyc_status: KycStatus; + kyc_submitted_at?: string; + accreditation_tier: "unaccredited" | "accredited" | "qualified"; + flagged: boolean; + flag_reason?: string; +} + +export async function buyFraction( + input: BuyFractionInput, + token?: string +): Promise { + const res = await fetch(`${API_BASE}/marketplace/listings/${input.listingId}/buy`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ quantity: input.quantity }), + }); + if (!res.ok) { + const message = await readConflictErrorMessage(res, "Failed to buy fraction"); + throw new Error(message); + } + return res.json(); +} + +export interface BuyFractionInput { + listingId: string; + /** Number of fractions to purchase. */ + quantity: number; +} + +export async function cancelLimitOrder(orderId: string): Promise { + const res = await fetch( + `${API_BASE}/marketplace/limit-orders/${encodeURIComponent(orderId)}/cancel`, + { method: "POST" } + ); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to cancel limit order"); + throw new Error(msg); + } +} + +export async function claimAllDividends(): Promise { + const res = await fetch(`${API_BASE}/investor/claim-dividends`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error("Failed to claim dividends"); + const payload = await res.json(); + // Backend may return the refreshed summary or just success. + if (payload && (payload.claimable || payload.claimsByCycle || payload.total_earned !== undefined)) { + return normalizeInvestorDividends(payload); + } + return fetchInvestorDividends(); +} + +export async function claimAllYieldTranches( + walletAddress: string +): Promise { + const res = await fetch(`${API_BASE}/investor/yield-vesting/claim-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ wallet_address: walletAddress }), + }); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to claim all tranches"); + throw new Error(msg); + } + return res.json(); +} + +export async function claimDividendCycle( + cycleId: string +): Promise<{ success: boolean; transaction_hash?: string }> { + const res = await fetch( + `${API_BASE}/investor/dividends/${encodeURIComponent(cycleId)}/claim`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + } + ); + if (!res.ok) throw new Error("Failed to claim dividend"); + return res.json(); +} + +export async function claimYieldTranche(trancheId: string): Promise { + const res = await fetch( + `${API_BASE}/investor/yield-vesting/${encodeURIComponent(trancheId)}/claim`, + { method: "POST" } + ); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to claim tranche"); + throw new Error(msg); + } + return res.json(); +} + +export interface DefaultResolution { + invoice_id: string; + invoice_title: string; + declaration_date: string; + collateral_forfeited: number; + total_recovery_distributed: number; + recovery_distribution_pending: boolean; + holders: DefaultResolutionHolder[]; + dispute_form_url: string; +} + +export async function deleteDraftInvoice( + id: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${id}`, { + method: "DELETE", + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to delete draft invoice"); + return res.json(); +} + +export interface EmailNotificationPreference { + event_type: EmailNotificationType; + email: boolean; +} + +export type EmailNotificationType = + | "settlement" + | "kyc_status" + | "watchlist_invoice_match" + | "secondary_market_sale" + | "invoice_decision"; + +export interface FeeTierInfo { + fee_percentage: number; + tier_label: string; + volume_24h: number; + tiers: FeeTierEntry[]; + is_lowest_tier: boolean; +} + +export async function fetchAccreditationStatus( + walletAddress: string, + token?: string +): Promise { + const res = await fetch( + `${API_BASE}/investors/${encodeURIComponent(walletAddress)}/accreditation`, + { headers: authHeaders(token) } + ); + if (!res.ok) throw new Error("Failed to fetch accreditation status"); + return normalizeAccreditationStatus(await res.json(), walletAddress); +} + +export async function fetchActiveLimitOrders( + walletAddress: string +): Promise { + const url = new URL( + `${API_BASE}/marketplace/limit-orders`, + typeof window !== "undefined" ? window.location.href : "http://localhost" + ); + url.searchParams.set("address", walletAddress); + url.searchParams.set("status", "open"); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error("Failed to fetch limit orders"); + const json = await res.json(); + return Array.isArray(json) ? json : (json.orders ?? []); +} + +export async function fetchAdminUsers( + search = "", + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/admin/users?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch users")); + } + return res.json(); +} + +export async function fetchCreatorProfile( + wallet: string, + cursor?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/creators/${wallet}?${params}`); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch creator profile")); + } + return normalizeCreatorProfile(await res.json()); +} + +export async function fetchDefaultResolution( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/default-resolution` + ); + if (!res.ok) throw new Error("Failed to fetch default resolution"); + return res.json(); +} + +export async function fetchEmailNotificationPreferences(): Promise { + const res = await fetch(`${API_BASE}/notifications/preferences/email`); + if (!res.ok) throw new Error("Failed to fetch notification preferences"); + return res.json(); +} + +export async function fetchFeeTier(): Promise { + const res = await fetch(`${API_BASE}/protocol/fee-tier`); + if (!res.ok) throw new Error("Failed to fetch fee tier"); + return normalizeFeeTierInfo(await res.json()); +} + +export async function fetchInvestorDividends(): Promise { + const res = await fetch(`${API_BASE}/investor/dividends`); + if (!res.ok) throw new Error("Failed to fetch dividends"); + return normalizeInvestorDividends(await res.json()); +} + +export async function fetchInvestorReputation( + wallet: string +): Promise { + const res = await fetch( + `${API_BASE}/investors/${encodeURIComponent(wallet)}/reputation` + ); + if (!res.ok) throw new Error("Failed to fetch investor reputation"); + return normalizeInvestorReputation(await res.json(), wallet); +} + +export async function fetchInvoiceDemandMetrics( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/demand-metrics` + ); + if (!res.ok) throw new Error("Failed to fetch demand metrics"); + return normalizeInvoiceDemandMetrics(await res.json(), invoiceId); +} + +export async function fetchInvoiceFundingProgress( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/funding-progress` + ); + if (!res.ok) throw new Error("Failed to fetch funding progress"); + return normalizeInvoiceFundingProgress(await res.json(), invoiceId); +} + +export async function fetchInvoicePriceHistory( + invoiceId: string, + range: PriceHistoryRangeParam = "all", + signal?: AbortSignal +): Promise { + const res = await fetch( + `${API_BASE}/marketplace/invoices/${invoiceId}/price-history?range=${range}`, + { signal } + ); + if (!res.ok) throw new Error("Failed to fetch invoice price history"); + + const payload = await res.json(); + return normalizeInvoicePriceHistory(payload, invoiceId); +} + +export async function fetchInvoiceProtection( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/protection` + ); + if (!res.ok) throw new Error("Failed to fetch sell tax info"); + return normalizeProtectionInfo(await res.json(), invoiceId); +} + +export async function fetchInvoiceRating( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/rating` + ); + if (!res.ok) throw new Error("Failed to fetch invoice rating"); + return normalizeInvoiceRating(await res.json(), invoiceId); +} + +export async function fetchIssuerEarnings( + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + const res = await fetch(`${API_BASE}/issuer/earnings?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch issuer earnings"); + const payload = await res.json(); + return { + summary: normalizeIssuerEarningsSummary(payload.summary ?? payload), + invoices: (payload.invoices ?? []).map(normalizeIssuerInvoiceEarning), + has_more: payload.has_more ?? false, + next_cursor: payload.next_cursor ?? null, + }; +} + +export async function fetchKycHistory( + wallet: string, + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/admin/users/${wallet}/kyc/history?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch KYC history")); + } + return res.json(); +} + +export async function fetchLeaderboard(): Promise { + const res = await fetch(`${API_BASE}/leaderboard`); + if (!res.ok) throw new Error("Failed to fetch leaderboard"); + const payload = await res.json(); + const list = Array.isArray(payload) ? payload : payload.investors ?? []; + return list.map(normalizeLeaderboardInvestor); +} + +export async function fetchNotificationPreferences(): Promise { + const res = await fetch(`${API_BASE}/notifications/preferences`); + if (!res.ok) throw new Error("Failed to fetch notification preferences"); + return res.json(); +} + +export async function fetchPlatformActivity( + limit = 20 +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + const res = await fetch(`${API_BASE}/activity?${params}`); + if (!res.ok) throw new Error("Failed to fetch platform activity"); + const payload = await res.json(); + const events = Array.isArray(payload) + ? payload + : payload.events ?? payload.activity ?? []; + return events + .map(normalizePlatformActivityEvent) + .slice(0, limit); +} + +export async function fetchResaleListings( + filters: ResaleListingFilters = {}, + signal?: AbortSignal +): Promise { + const params = buildResaleListingParams(filters); + const query = params.toString(); + const res = await fetch( + `${API_BASE}/marketplace/listings${query ? `?${query}` : ""}`, + { signal } + ); + if (!res.ok) throw new Error("Failed to fetch resale listings"); + + const payload = await res.json(); + const rawList: any[] = Array.isArray(payload) + ? payload + : Array.isArray(payload?.listings) + ? payload.listings + : []; + + return { + listings: rawList.map(normalizeResaleListing), + has_more: Boolean(payload?.has_more ?? false), + next_cursor: payload?.next_cursor ?? null, + }; +} + +export async function fetchYieldVesting( + walletAddress: string +): Promise { + const res = await fetch( + `${API_BASE}/investor/yield-vesting?address=${encodeURIComponent(walletAddress)}` + ); + if (!res.ok) throw new Error("Failed to fetch yield vesting"); + const json = await res.json(); + return Array.isArray(json) ? json : (json.schedules ?? []); +} + +export async function flagUser( + wallet: string, + reason: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/flag`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ reason }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to flag user")); + } + return res.json(); +} + +export function getReputationTier(score: number): ReputationTier { + if (score >= 800) return "Verified"; + if (score >= 500) return "Trusted"; + return "New"; +} + +export interface InvestorReputation { + wallet: string; + score: number; + tier: ReputationTier; + investments_completed: number; + governance_votes: number; + platform_tenure_days: number; + history: ReputationScoreHistoryPoint[]; +} + +export interface InvoiceDemandMetrics { + invoice_id: string; + /** Unique investors backing the invoice, as reported by the API. */ + investor_count: number; + /** Face value committed in the last 24 hours. */ + raised_last_24h: number; + /** Total raised, so the 24h rate can be expressed as a percentage. */ + raised: number; + target: number; + /** When the invoice was funded; `null` while it is still open. */ + funded_at: string | null; +} + +export interface InvoiceFundingProgress { + invoice_id: string; + raised: number; + target: number; + investor_count: number; + is_fully_funded: boolean; +} + +export interface InvoicePriceHistory { + invoice_id: string; + /** Ask price of the invoice's current active listing, or null if it isn't currently listed. */ + current_ask_price: number | null; + history: InvoicePriceHistoryPoint[]; +} + +export interface InvoicePriceHistoryPoint { + date: string; + price: number; + quantity: number; +} + +export interface InvoiceProtectionInfo { + invoice_id: string; + sell_tax_percentage: number; + buyback_pool_balance: number; +} + +export interface InvoiceRatingSummary { + invoice_id: string; + average_rating: number; + rating_count: number; + user_rating?: number | null; +} + +export interface InvoiceYieldVesting { + invoice_id: string; + invoice_title: string; + total_yield: number; + claimed_yield: number; + tranches: YieldTranche[]; +} + +export function isAccreditedForInvoice( + tier: AccreditationTier, + faceValue: number, + threshold: number +): boolean { + if (faceValue < threshold) return true; + return tier === "accredited" || tier === "qualified"; +} + +export interface IssuerInvoiceEarning { + invoice_id: string; + invoice_title: string; + face_value: number; + funded_date: string; + gross_proceeds: number; + platform_fee: number; + net_payout: number; + payout_status: IssuerPayoutStatus; + transaction_hash?: string; +} + +export interface LeaderboardInvestor { + address: string; + total_committed: number; + invoice_count: number; +} + +export interface LimitOrder { + id: string; + invoice_id: string; + investor_address: string; + fraction_amount: number; + max_price_per_fraction: number; + total_escrowed: number; + status: "open" | "filled" | "cancelled" | "expired"; + created_at: string; +} + +export function normalizeAdminUsersExtendedResponse( + raw: any +): AdminUsersExtendedResponse { + const list = Array.isArray(raw) ? raw : (raw?.users ?? []); + return { + users: (Array.isArray(list) ? list : []).map(normalizeAdminUserRowExtended), + has_more: Boolean(raw?.has_more ?? false), + next_cursor: raw?.next_cursor ?? null, + }; +} + +export function normalizeNotification(raw: any): NotificationItem { + return { + ...raw, + id: raw.id, + title: raw.title ?? undefined, + message: raw.message ?? undefined, + body: raw.body ?? raw.description ?? undefined, + link: raw.link ?? raw.url ?? undefined, + type: raw.type ?? raw.event_type ?? raw.eventType ?? undefined, + invoice_id: raw.invoice_id ?? raw.invoiceId ?? undefined, + read: Boolean(raw.read ?? raw.is_read ?? false), + created_at: raw.created_at ?? raw.createdAt ?? undefined, + }; +} + +export interface NotificationPreference { + event_type: NotificationEventType; + email: boolean; + in_app: boolean; +} + +export async function placeLimitOrder( + input: PlaceLimitOrderInput +): Promise { + const res = await fetch(`${API_BASE}/marketplace/limit-orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invoice_id: input.invoice_id, + fraction_amount: input.fraction_amount, + max_price_per_fraction: input.max_price_per_fraction, + }), + }); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to place limit order"); + throw new Error(msg); + } + return res.json(); +} + +export interface PlaceLimitOrderInput { + invoice_id: string; + fraction_amount: number; + max_price_per_fraction: number; +} + +export interface PlatformActivityEvent { + id: string; + type: PlatformActivityEventType; + invoice_id: string; + title: string; + amount?: number; + created_at: string; +} + +export type PlatformActivityEventType = + | "new_investment" + | "invoice_funded" + | "invoice_settled" + | "new_listing"; + +export type PriceHistoryRangeParam = "7d" | "30d" | "all"; + +export interface ResaleListingFilters { + invoiceId?: string; + invoiceType?: string; + minYield?: number; + maxYield?: number; + minPrice?: number; + maxPrice?: number; + /** Only listings maturing within this many days. */ + maxDaysToMaturity?: number; +} + +export async function saveEmailNotificationPreferences( + preferences: EmailNotificationPreference[] +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/notifications/preferences/email`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ preferences }), + }); + if (!res.ok) throw new Error("Failed to save notification preferences"); + return res.json(); +} + +export function sortNotificationsByRecency( + notifications: NotificationItem[] +): NotificationItem[] { + return [...notifications].sort((a, b) => { + const aTime = a.created_at ? new Date(a.created_at).getTime() : 0; + const bTime = b.created_at ? new Date(b.created_at).getTime() : 0; + // Items with no timestamp sort last rather than jumping to the top. + if (a.created_at && !b.created_at) return -1; + if (!a.created_at && b.created_at) return 1; + return bTime - aTime; + }); +} + +export async function submitDraftInvoice( + id: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${id}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + }); + if (!res.ok) throw new Error("Failed to submit invoice for review"); + return res.json(); +} + +export async function submitInvoiceRating( + invoiceId: string, + rating: number, + walletAddress?: string, + token?: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/rating`, + { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ rating, wallet: walletAddress }), + } + ); + if (!res.ok) throw new Error("Failed to submit rating"); + return normalizeInvoiceRating(await res.json(), invoiceId); +} + +export async function suspendAdminUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/suspend`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to suspend user")); + } + return res.json(); +} + +export async function transferInvoiceFractions( + invoiceId: string, + recipient: string, + quantity: number, + walletAddress: string, + token?: string +): Promise<{ success: boolean; transaction_hash: string }> { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/transfer-fractions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, + body: JSON.stringify({ + recipient, + quantity, + wallet: walletAddress, + }), + } + ); + if (!res.ok) throw new Error("Failed to transfer invoice fractions"); + return res.json(); +} + +export async function transferInvoicePosition( + invoiceId: string, + buyer: string, + salePriceXlm: number, + walletAddress: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${invoiceId}/transfer-position`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, + body: JSON.stringify({ + buyer, + sale_price: salePriceXlm, + wallet: walletAddress, + }), + }); + if (!res.ok) throw new Error("Failed to transfer position"); + return res.json(); +} + +export async function unflagUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/unflag`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to unflag user")); + } + return res.json(); +} + +export async function unsuspendAdminUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/unsuspend`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to unsuspend user")); + } + return res.json(); +} + +export async function updateAdminUserRole( + wallet: string, + role: AdminUserRole, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/role`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ role }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to update role")); + } + return res.json(); +} + +export async function updateUserKycStatus( + wallet: string, + status: KycStatus, + reason?: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/kyc`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ status, reason }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to update KYC status")); + } + return res.json(); +} + +export async function uploadDocumentToIpfs(file: File, token?: string): Promise { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch(`${API_BASE}/ipfs/upload`, { + method: "POST", + headers: authHeaders(token), + body: formData, + }); + if (!res.ok) throw new Error("Document upload failed"); + return res.json(); +} + +export async function withdrawIssuerEarnings( + invoiceId: string, + token?: string +): Promise<{ success: boolean; transaction_hash?: string }> { + const res = await fetch(`${API_BASE}/issuer/earnings/${invoiceId}/withdraw`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + }); + if (!res.ok) throw new Error("Failed to submit withdrawal"); + return res.json(); +} + +export interface YieldTranche { + id: string; + invoice_id: string; + invoice_title: string; + tranche_index: number; + total_tranches: number; + total_yield: number; + amount: number; + claimed: boolean; + release_timestamp: string; +} + +export interface ProtocolStatus { + min_investment: number; + [key: string]: unknown; +} + +export interface BuyFractionResult { + success: boolean; + transaction_hash?: string; + /** Remaining quantity reported by the contract after the transfer. */ + remaining_quantity?: number; +} + +export interface InvestorDividendsResponse { + total_earned: number; + total_pending: number; + claimable: DividendCycle[]; + history: DividendClaimRecord[]; +} + +export interface ClaimTrancheResult { + success: boolean; + transaction_hash?: string; + claimed_amount: number; +} + +export interface DefaultResolutionHolder { + address: string; + investment_amount: number; + recovery_amount: number; + recovery_paid: boolean; +} + +export interface FeeTierEntry { + tier: string; + min_volume: number; + fee_percentage: number; +} + +export interface AdminUsersResponse { + users: AdminUserRow[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface CreatorProfileResponse { + wallet: string; + display_name?: string | null; + displayName?: string | null; + joined_at: string; + joinedAt?: string; + kyc_verified: boolean; + kycVerified?: boolean; + stats: CreatorProfileStats; + invoices: CreatorProfileInvoice[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface IssuerEarningsResponse { + summary: IssuerEarningsSummary; + invoices: IssuerInvoiceEarning[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface KycHistoryResponse { + history: KycHistoryEntry[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface ResaleListingsResponse { + listings: ResaleListing[]; + has_more: boolean; + next_cursor: string | null; +} + +export type ReputationTier = "Verified" | "Trusted" | "New"; + +export interface ReputationScoreHistoryPoint { + date: string; + score: number; +} + +export type IssuerPayoutStatus = "pending" | "paid" | "processing"; + +export interface AdminUsersExtendedResponse { + users: AdminUserRowExtended[]; + has_more: boolean; + next_cursor: string | null; +} + +export function normalizeAdminUserRowExtended(raw: any): AdminUserRowExtended { + const accreditation = String( + raw.accreditation_tier ?? raw.accreditationTier ?? "unaccredited" + ).toLowerCase(); + const flagged = Boolean(raw.flagged ?? raw.is_flagged ?? raw.isFlagged ?? false); + + return { + wallet: String(raw.wallet ?? raw.address ?? ""), + role: (raw.role ?? "user") as AdminUserRole, + suspended: Boolean(raw.suspended ?? false), + joined_at: String(raw.joined_at ?? raw.joinedAt ?? raw.created_at ?? ""), + email: String(raw.email ?? ""), + kyc_status: flagged ? "flagged" : normalizeKycStatus(raw.kyc_status ?? raw.kycStatus), + kyc_submitted_at: + raw.kyc_submitted_at ?? raw.kycSubmittedAt ?? raw.submitted_at ?? undefined, + accreditation_tier: (ADMIN_USER_ACCREDITATION_TIERS as string[]).includes(accreditation) + ? (accreditation as AdminUserRowExtended["accreditation_tier"]) + : "unaccredited", + flagged, + flag_reason: raw.flag_reason ?? raw.flagReason ?? undefined, + }; +} + +export interface PlaceLimitOrderResult { + success: boolean; + order: LimitOrder; + transaction_hash?: string; +} + +export interface IpfsUploadResult { + cid: string; + url: string; +} + +export interface DividendCycle { + cycle: string; + cycle_id?: string; + amount: number; + distribution_date: string; + claimable: boolean; + claimed?: boolean; + transaction_hash?: string | null; +} + +export interface DividendClaimRecord { + cycle: string; + amount: number; + claimed_at: string; + transaction_hash: string; +} + +export interface CreatorProfileStats { + total_invoices: number; + total_funded: number; + settlement_success_rate: number; +} + +export interface CreatorProfileInvoice { + id: string; + title: string; + amount: number; + funded_amount: number; + status: Invoice["status"]; + created_at: string; +} + +export interface IssuerEarningsSummary { + gross_proceeds: number; + platform_fee: number; + net_payout: number; +} + +export interface KycHistoryEntry { + id: string; + wallet: string; + status: KycStatus; + submitted_at: string; + reviewed_at?: string; + reviewed_by?: string; + rejection_reason?: string; + documents: string[]; +} + +/* ─── Restored internal helpers ─── */ + +async function readConflictErrorMessage(res: Response, fallback: string): Promise { + try { + const payload = await res.json(); + const message = payload?.message ?? payload?.error; + return typeof message === "string" && message.length > 0 ? message : fallback; + } catch { + return fallback; + } +} + +function normalizeInvestorDividends(raw: any): InvestorDividendsResponse { + const claimableRaw: any[] = Array.isArray(raw.claimable) + ? raw.claimable + : Array.isArray(raw.claimsByCycle) + ? raw.claimsByCycle + : Array.isArray(raw.cycles) + ? raw.cycles + : []; + const historyRaw: any[] = Array.isArray(raw.history) + ? raw.history + : Array.isArray(raw.claimed) + ? raw.claimed + : []; + const claimable = claimableRaw.map(normalizeDividendCycle); + const history = historyRaw.map(normalizeDividendClaimRecord); + const total_pending = Number( + raw.total_pending ?? raw.totalPending ?? raw.pendingClaims ?? 0 + ); + const total_earned = Number( + raw.total_earned ?? raw.totalEarned ?? raw.totalEarnedXlm ?? 0 + ); + // Aggregate fallback when backend only sends cycles. + const pendingFallback = claimable + .filter((c) => c.claimable && !c.claimed) + .reduce((sum, c) => sum + c.amount, 0); + const earnedFallback = + pendingFallback + history.reduce((sum, h) => sum + h.amount, 0); + return { + total_earned: total_earned || earnedFallback, + total_pending: raw.total_pending !== undefined ? total_pending : pendingFallback, + claimable, + history, + }; +} + +function normalizeAccreditationStatus(raw: any, wallet: string): AccreditationStatus { + return { + wallet: raw.wallet ?? raw.address ?? wallet, + tier: normalizeAccreditationTier(raw.tier ?? raw.accreditation_tier ?? raw.accreditationTier), + high_value_threshold: Number( + raw.high_value_threshold ?? raw.highValueThreshold ?? raw.threshold ?? 100_000 + ), + }; +} + +function normalizeCreatorProfile(raw: any): CreatorProfileResponse { + return { + wallet: raw.wallet ?? raw.address ?? "", + display_name: raw.display_name ?? raw.displayName ?? null, + joined_at: raw.joined_at ?? raw.joinedAt ?? raw.created_at ?? "", + kyc_verified: Boolean(raw.kyc_verified ?? raw.kycVerified), + stats: { + total_invoices: raw.stats?.total_invoices ?? raw.total_invoices ?? 0, + total_funded: raw.stats?.total_funded ?? raw.total_funded ?? 0, + settlement_success_rate: + raw.stats?.settlement_success_rate ?? raw.settlement_success_rate ?? 0, + }, + invoices: raw.invoices ?? [], + has_more: Boolean(raw.has_more), + next_cursor: raw.next_cursor ?? null, + }; +} + +function normalizeFeeTierInfo(raw: any): FeeTierInfo { + const tiers = Array.isArray(raw.tiers) + ? raw.tiers.map(normalizeFeeTierEntry) + : FEE_TIERS; + const fee_percentage = Number( + raw.fee_percentage ?? raw.feePercentage ?? raw.fee ?? 0 + ); + const tier_label = String( + raw.tier_label ?? raw.tierLabel ?? raw.tier ?? "" + ); + const volume_24h = Number( + raw.volume_24h ?? raw.volume24h ?? raw.volume ?? 0 + ); + const lowestFee = Math.min(...tiers.map((t: FeeTierEntry) => t.fee_percentage)); + return { + fee_percentage, + tier_label, + volume_24h, + tiers, + is_lowest_tier: + typeof raw.is_lowest_tier === "boolean" + ? raw.is_lowest_tier + : fee_percentage <= lowestFee, + }; +} + +function normalizeInvestorReputation(raw: any, wallet: string): InvestorReputation { + const score = Number( + raw.score ?? raw.reputation_score ?? raw.reputationScore ?? 0 + ); + const breakdown = raw.breakdown ?? raw; + return { + wallet: raw.wallet ?? raw.address ?? wallet, + score, + tier: (raw.tier as ReputationTier) ?? getReputationTier(score), + investments_completed: Number( + raw.investments_completed ?? + breakdown.investments_completed ?? + breakdown.investmentsCompleted ?? + raw.investmentsCompleted ?? + 0 + ), + governance_votes: Number( + raw.governance_votes ?? + breakdown.governance_votes ?? + breakdown.governanceVotes ?? + raw.governanceVotes ?? + 0 + ), + platform_tenure_days: Number( + raw.platform_tenure_days ?? + breakdown.platform_tenure_days ?? + breakdown.platformTenureDays ?? + raw.platformTenureDays ?? + raw.tenure_days ?? + 0 + ), + history: normalizeReputationHistory(raw.history ?? raw.score_history ?? raw), + }; +} + +function normalizeInvoiceDemandMetrics(raw: any, invoiceId: string): InvoiceDemandMetrics { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + investor_count: Number(raw.investor_count ?? raw.investorCount ?? 0), + raised_last_24h: Number( + raw.raised_last_24h ?? raw.raisedLast24h ?? raw.last_24h ?? 0 + ), + raised: Number(raw.raised ?? raw.funded ?? 0), + target: Number(raw.target ?? raw.amount ?? raw.face_value ?? 0), + funded_at: raw.funded_at ?? raw.fundedAt ?? null, + }; +} + +function normalizeInvoiceFundingProgress(raw: any, invoiceId: string): InvoiceFundingProgress { + const target = Number(raw.target ?? raw.amount ?? raw.face_value ?? 0); + const raised = Number(raw.raised ?? raw.funded ?? raw.raised_amount ?? 0); + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + raised, + target, + investor_count: Number(raw.investor_count ?? raw.investorCount ?? 0), + is_fully_funded: + typeof raw.is_fully_funded === "boolean" + ? raw.is_fully_funded + : target > 0 && raised >= target, + }; +} + +function normalizeInvoicePriceHistory(raw: any, invoiceId: string): InvoicePriceHistory { + const list = Array.isArray(raw) ? raw : raw?.history ?? raw?.points ?? []; + const rawCurrent = raw?.current_ask_price ?? raw?.currentAskPrice; + return { + invoice_id: String(raw?.invoice_id ?? raw?.invoiceId ?? invoiceId), + current_ask_price: + rawCurrent !== undefined && rawCurrent !== null ? Number(rawCurrent) : null, + history: (Array.isArray(list) ? list : []).map(normalizeInvoicePriceHistoryPoint), + }; +} + +function normalizeProtectionInfo(raw: any, invoiceId: string): InvoiceProtectionInfo { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + sell_tax_percentage: Number( + raw.sell_tax_percentage ?? raw.sellTaxPercentage ?? raw.sell_tax ?? 0 + ), + buyback_pool_balance: Number( + raw.buyback_pool_balance ?? + raw.buybackPoolBalance ?? + raw.buyback_pool ?? + 0 + ), + }; +} + +function normalizeInvoiceRating(raw: any, invoiceId: string): InvoiceRatingSummary { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + average_rating: Number(raw.average_rating ?? raw.averageRating ?? raw.average ?? 0), + rating_count: Number(raw.rating_count ?? raw.ratingCount ?? raw.count ?? 0), + user_rating: + raw.user_rating !== undefined && raw.user_rating !== null + ? Number(raw.user_rating) + : (raw.userRating !== undefined && raw.userRating !== null + ? Number(raw.userRating) + : null), + }; +} + +function normalizeIssuerEarningsSummary(raw: any): IssuerEarningsSummary { + return { + gross_proceeds: Number(raw.gross_proceeds ?? raw.grossProceeds ?? 0), + platform_fee: Number(raw.platform_fee ?? raw.platformFee ?? 0), + net_payout: Number(raw.net_payout ?? raw.netPayout ?? 0), + }; +} + +function normalizeIssuerInvoiceEarning(raw: any): IssuerInvoiceEarning { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? raw.id ?? "", + invoice_title: raw.invoice_title ?? raw.invoiceTitle ?? raw.title ?? "", + face_value: Number(raw.face_value ?? raw.faceValue ?? raw.amount ?? 0), + funded_date: raw.funded_date ?? raw.fundedDate ?? raw.funded_at ?? "", + gross_proceeds: Number(raw.gross_proceeds ?? raw.grossProceeds ?? 0), + platform_fee: Number(raw.platform_fee ?? raw.platformFee ?? 0), + net_payout: Number(raw.net_payout ?? raw.netPayout ?? 0), + payout_status: raw.payout_status ?? raw.payoutStatus ?? "pending", + transaction_hash: raw.transaction_hash ?? raw.transactionHash ?? undefined, + }; +} + +function normalizeLeaderboardInvestor(raw: any): LeaderboardInvestor { + return { + address: + raw.address ?? raw.wallet ?? raw.wallet_address ?? raw.walletAddress ?? "", + total_committed: + raw.total_committed ?? raw.totalCommitted ?? raw.total ?? 0, + invoice_count: + raw.invoice_count ?? raw.invoiceCount ?? raw.invoices_count ?? 0, + }; +} + +function normalizePlatformActivityEvent(raw: any): PlatformActivityEvent { + return { + id: String(raw.id ?? raw.event_id ?? raw.eventId ?? ""), + type: normalizePlatformActivityType( + raw.type ?? raw.event_type ?? raw.eventType + ), + invoice_id: String( + raw.invoice_id ?? raw.invoiceId ?? raw.invoice ?? "" + ), + title: String(raw.title ?? raw.invoice_title ?? raw.description ?? ""), + amount: + raw.amount !== undefined && raw.amount !== null + ? Number(raw.amount) + : undefined, + created_at: String( + raw.created_at ?? raw.createdAt ?? raw.timestamp ?? "" + ), + }; +} + +function buildResaleListingParams(filters: ResaleListingFilters = {}): URLSearchParams { + const params = new URLSearchParams(); + if (filters.invoiceType && filters.invoiceType !== "all") { + params.set("invoice_type", filters.invoiceType); + } + if (filters.minYield !== undefined && filters.minYield > 0) { + params.set("min_yield", String(filters.minYield)); + } + if (filters.maxYield !== undefined && filters.maxYield > 0) { + params.set("max_yield", String(filters.maxYield)); + } + if (filters.minPrice !== undefined && filters.minPrice > 0) { + params.set("min_price", String(filters.minPrice)); + } + if (filters.maxPrice !== undefined && filters.maxPrice > 0) { + params.set("max_price", String(filters.maxPrice)); + } + if (filters.maxDaysToMaturity !== undefined && filters.maxDaysToMaturity > 0) { + params.set("max_days_to_maturity", String(filters.maxDaysToMaturity)); + } + return params; +} + +function normalizeResaleListing(raw: any): ResaleListing { + const quantity = Number(raw.quantity ?? raw.shares ?? raw.shares_offered ?? 0); + const remaining = Number( + raw.remaining_quantity ?? raw.remaining ?? raw.shares_remaining ?? quantity + ); + + return { + id: String(raw.id ?? raw.listing_id), + invoice_id: String(raw.invoice_id ?? raw.invoiceId ?? ""), + invoice_title: String(raw.invoice_title ?? raw.invoiceTitle ?? raw.title ?? ""), + invoice_type: String(raw.invoice_type ?? raw.invoiceType ?? "other"), + seller: String(raw.seller ?? raw.seller_address ?? ""), + quantity, + remaining_quantity: remaining, + ask_price: Number(raw.ask_price ?? raw.askPrice ?? raw.price ?? 0), + maturity_value: Number( + raw.maturity_value ?? raw.maturityValue ?? raw.face_value ?? 0 + ), + maturity_date: String(raw.maturity_date ?? raw.maturityDate ?? raw.due_date ?? ""), + listed_at: String(raw.listed_at ?? raw.listedAt ?? raw.created_at ?? ""), + status: (raw.status ?? "active") as ResaleListingStatus, + }; +} + +function normalizeKycStatus(raw: any): KycStatus { + const v = String(raw ?? "").toLowerCase().replace(/[-\s]/g, "_"); + if ((KYC_STATUSES as string[]).includes(v)) return v as KycStatus; + if (v === "notsubmitted" || v === "none" || v === "missing") return "not_submitted"; + if (v === "in_review" || v === "inreview" || v === "under_review") return "flagged"; + return "not_submitted"; +} + +const ADMIN_USER_ACCREDITATION_TIERS: AdminUserRowExtended["accreditation_tier"][] = [ + "unaccredited", + "accredited", + "qualified", +]; + +function normalizeDividendCycle(raw: any): DividendCycle { + const cycle = String(raw.cycle ?? raw.cycle_id ?? raw.cycleId ?? raw.id ?? ""); + const amount = Number(raw.amount ?? raw.claimable_amount ?? 0); + const distribution_date = String( + raw.distribution_date ?? raw.distributionDate ?? raw.date ?? raw.created_at ?? "" + ); + const claimed = Boolean(raw.claimed ?? false); + return { + cycle, + cycle_id: raw.cycle_id ?? raw.cycleId ?? cycle, + amount, + distribution_date, + claimable: typeof raw.claimable === "boolean" ? raw.claimable : !claimed, + claimed, + transaction_hash: raw.transaction_hash ?? raw.transactionHash ?? null, + }; +} + +function normalizeDividendClaimRecord(raw: any): DividendClaimRecord { + return { + cycle: String(raw.cycle ?? raw.cycle_id ?? ""), + amount: Number(raw.amount ?? 0), + claimed_at: String(raw.claimed_at ?? raw.claimedAt ?? raw.date ?? ""), + transaction_hash: String(raw.transaction_hash ?? raw.transactionHash ?? raw.tx_hash ?? ""), + }; +} + +function normalizeAccreditationTier(raw: any): AccreditationTier { + const v = String(raw ?? "").toLowerCase(); + if ((ACCREDITATION_TIERS as string[]).includes(v)) return v as AccreditationTier; + if (v === "none" || v === "basic") return "unaccredited"; + if (v === "standard" || v === "verified") return "accredited"; + if (v === "institutional" || v === "sophisticated") return "qualified"; + return "unaccredited"; +} + +function normalizeFeeTierEntry(raw: any): FeeTierEntry { + return { + tier: String(raw.tier ?? raw.label ?? raw.name ?? ""), + min_volume: Number(raw.min_volume ?? raw.minVolume ?? raw.threshold ?? 0), + fee_percentage: Number( + raw.fee_percentage ?? raw.feePercentage ?? raw.fee ?? 0 + ), + }; +} + +export const FEE_TIERS: FeeTierEntry[] = [ + { tier: "Standard", min_volume: 0, fee_percentage: 2.5 }, + { tier: "Silver", min_volume: 100_000, fee_percentage: 2.0 }, + { tier: "Gold", min_volume: 500_000, fee_percentage: 1.5 }, + { tier: "Platinum", min_volume: 1_000_000, fee_percentage: 1.0 }, +]; + +function normalizeReputationHistory(raw: any): ReputationScoreHistoryPoint[] { + const list = Array.isArray(raw) ? raw : raw?.history ?? raw?.points ?? []; + return (Array.isArray(list) ? list : []).map((p: any) => ({ + date: String(p.date ?? p.timestamp ?? p.created_at ?? ""), + score: Number(p.score ?? p.value ?? 0), + })); +} + +function normalizeInvoicePriceHistoryPoint(raw: any): InvoicePriceHistoryPoint { + return { + date: String(raw.date ?? raw.timestamp ?? raw.listed_at ?? raw.created_at ?? ""), + price: Number(raw.price ?? raw.ask_price ?? raw.askPrice ?? 0), + quantity: Number(raw.quantity ?? raw.qty ?? 0), + }; +} + +function normalizePlatformActivityType(raw: any): PlatformActivityEventType { + const v = String(raw ?? "").toLowerCase(); + if ((PLATFORM_ACTIVITY_TYPES as string[]).includes(v)) + return v as PlatformActivityEventType; + // Tolerate backend aliases. + if (v === "investment" || v === "new investment") return "new_investment"; + if (v === "funded" || v === "invoice fully funded") return "invoice_funded"; + if (v === "settled" || v === "settlement") return "invoice_settled"; + if (v === "listing" || v === "new invoice") return "new_listing"; + return "new_listing"; +} + +export type ResaleListingStatus = "active" | "sold" | "cancelled"; + +const KYC_STATUSES: KycStatus[] = [ + "pending", + "approved", + "rejected", + "flagged", + "not_submitted", +]; + +const ACCREDITATION_TIERS: AccreditationTier[] = [ + "unaccredited", + "accredited", + "qualified", +]; + +const PLATFORM_ACTIVITY_TYPES: PlatformActivityEventType[] = [ + "new_investment", + "invoice_funded", + "invoice_settled", + "new_listing", +]; diff --git a/lib/apiErrors.ts b/lib/apiErrors.ts index 8f138f0..f9a24e5 100644 --- a/lib/apiErrors.ts +++ b/lib/apiErrors.ts @@ -43,26 +43,34 @@ function readMessage(value: unknown): string | null { export function extractApiErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; - let envelope: ApiErrorEnvelope | null = null; if (isRecord(error)) { - // axios-style errors carry the payload on `response.data` - const data = isRecord(error.response) ? error.response.data : null; - envelope = (data ?? error) as ApiErrorEnvelope; - } + // axios-style errors carry the payload on `response.data`, while the HTTP + // status sits on `response` itself. + const response = isRecord(error.response) ? error.response : null; + const data = response ? response.data : null; + const envelope = (isRecord(data) ? data : error) as ApiErrorEnvelope; - if (envelope) { const direct = readMessage(envelope.message) ?? readMessage(envelope.error) ?? readMessage(envelope.detail); if (direct) return direct; - if (typeof envelope.status === "number") { - return `Request failed with status ${envelope.status}`; + const status = + typeof envelope.status === "number" + ? envelope.status + : response && typeof response.status === "number" + ? response.status + : null; + if (status !== null) { + return `Request failed with status ${status}`; } if (typeof envelope.statusText === "string" && envelope.statusText) { return envelope.statusText; } + if (response && typeof response.statusText === "string" && response.statusText) { + return response.statusText; + } } return "Something went wrong. Please try again."; diff --git a/lib/performance-logger.ts b/lib/performance-logger.ts index 33843b6..25b9df6 100644 --- a/lib/performance-logger.ts +++ b/lib/performance-logger.ts @@ -1,6 +1,6 @@ import { logInfo } from "@/lib/logger"; -export type PageName = "seller_dashboard" | "investor_portfolio"; +export type PageName = "seller_dashboard" | "investor_portfolio" | "investor_portfolio_page"; export interface PagePerformanceMetrics { page_name: PageName; diff --git a/tsconfig.json b/tsconfig.json index d81d4ee..07a4cd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,10 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", + "types": [ + "node", + "vitest/globals" + ], "incremental": true, "plugins": [ { diff --git a/vitest.setup.ts b/vitest.setup.ts index 6543801..7f28e32 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -53,3 +53,9 @@ if (typeof window !== "undefined" && !window.localStorage) { configurable: true, }); } + +// jsdom does not implement scrollIntoView, which the admin users table calls +// when a dropdown opens. A no-op keeps those interactions testable. +if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} From 2e4a170ca3b6e9df4e7831990a3254f9803bf2d4 Mon Sep 17 00:00:00 2001 From: solomonadzape95 Date: Tue, 20 Oct 2026 00:00:56 +0100 Subject: [PATCH 4/4] test: use the shared card-provider helper comment Keep render-with-card-providers identical across the open feature branches so merging them is conflict-free. --- .../marketplace/__tests__/render-with-card-providers.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/components/marketplace/__tests__/render-with-card-providers.tsx b/components/marketplace/__tests__/render-with-card-providers.tsx index 0d20183..edff008 100644 --- a/components/marketplace/__tests__/render-with-card-providers.tsx +++ b/components/marketplace/__tests__/render-with-card-providers.tsx @@ -4,13 +4,8 @@ import type { ReactElement } from "react"; import { ComparisonProvider } from "@/components/marketplace/InvoiceComparisonContext"; /** - * Renders a component with the providers `InvoiceCard` needs now that it - * carries the watchlist bell (#474): React Query for the registry hook and the - * comparison context for the compare toggle. - * - * The wallet context intentionally stays at its disconnected default — these - * tests are about the card, not the watchlist, and a disconnected wallet simply - * disables the bell. + * `InvoiceCard` renders children that use React Query (demand metrics) and the + * comparison context, so tests need both providers. */ export function renderWithCardProviders(ui: ReactElement) { const queryClient = new QueryClient({