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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 11 additions & 104 deletions app/(dashboard)/investor/watchlist/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
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 (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Watchlist</h1>
<p className="text-muted-foreground">Loading...</p>
</div>
);
}

return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Watchlist</h1>

{watchedIds.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No invoices in your watchlist yet.
</p>
<Link
href="/marketplace"
className="text-primary hover:underline text-sm mt-2 inline-block"
>
Browse the marketplace to find invoices to watch
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{watchedIds.map((id) => (
<Card key={id}>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-full bg-secondary flex items-center justify-center text-xs font-mono shrink-0">
{id.slice(0, 2)}
</div>
<div className="min-w-0">
<p className="font-mono text-sm truncate">{id}</p>
<p className="text-xs text-muted-foreground">
Added to watchlist
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link href={`/marketplace/${id}`}>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => removeInvoice(id)}
aria-label="Remove from watchlist"
>
<EyeOff className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
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");
}
16 changes: 14 additions & 2 deletions app/(dashboard)/seller/publish/success/page.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -54,3 +54,15 @@ export default function PublishSuccessPage() {
</main>
);
}

/**
* `useSearchParams` requires a Suspense boundary so this route can still be
* statically prerendered. The content is client-only anyway.
*/
export default function PublishSuccessPage() {
return (
<Suspense fallback={null}>
<PublishSuccessContent />
</Suspense>
);
}
113 changes: 113 additions & 0 deletions app/(dashboard)/watchlist/__tests__/watchlist-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return render(<WatchlistPage />, { wrapper: Wrapper });
}

function makeInvoice(overrides: Partial<api.InvoiceDetail> = {}): 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();
});
});
Loading
Loading