- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
);
@@ -246,58 +408,69 @@ export default function MarketplacePage() {
Invoice Marketplace
-
-
- {isFetching && !isLoading && (
-
-
- Refreshing…
-
- )}
-
-
-
-
+ {/* Collapsible Filter Panel on the left */}
+
-
-
- {filtered.length === 0 ? (
-
- No invoices match your filters.
-
- ) : (
- filtered.map((invoice) => (
-
- ))
- )}
- {isFetchingNextPage &&
- Array.from({ length: 3 }).map((_, i) => (
-
- ))}
- {hasNextPage &&
}
- {!hasNextPage && filtered.length > 0 && (
-
- No more invoices
-
- )}
+
+
+
+ {isFetching && !isLoading && (
+
+
+ Refreshing…
+
+ )}
+
+
+
+
+
+
+
+ {filtered.length === 0 ? (
+
+ No invoices match your filters.
+
+ ) : (
+ filtered.map((invoice) => (
+
+ ))
+ )}
+ {isFetchingNextPage &&
+ Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+ {hasNextPage &&
}
+ {!hasNextPage && filtered.length > 0 && (
+
+ No more invoices
+
+ )}
+
+
);
diff --git a/components/admin/AdminInvoicesReview.tsx b/components/admin/AdminInvoicesReview.tsx
new file mode 100644
index 0000000..08b03d0
--- /dev/null
+++ b/components/admin/AdminInvoicesReview.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "@/context/AuthContext";
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ fetchAdminInvoices,
+ approveAdminInvoice,
+ rejectAdminInvoice,
+ AdminInvoiceRow,
+} from "@/lib/api";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DocumentPreview } from "@/components/invoices/DocumentPreview";
+import { toast } from "sonner";
+import { FileText, CheckCircle, XCircle } from "lucide-react";
+
+export function AdminInvoicesReview() {
+ usePageTitle("Admin Invoice Review");
+ const { jwt } = useAuth();
+ const queryClient = useQueryClient();
+
+ const [selectedDocUrl, setSelectedDocUrl] = useState
(null);
+ const [rejectingInvoiceId, setRejectingInvoiceId] = useState(null);
+ const [rejectionReason, setRejectionReason] = useState("");
+
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isError,
+ } = useInfiniteQuery({
+ queryKey: ["admin-invoices-pending", jwt],
+ queryFn: ({ pageParam }) =>
+ fetchAdminInvoices("pending", pageParam as string | undefined, jwt ?? undefined),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined,
+ enabled: !!jwt,
+ });
+
+ const approveMutation = useMutation({
+ mutationFn: (invoiceId: string) => approveAdminInvoice(invoiceId, jwt ?? undefined),
+ onSuccess: (_, invoiceId) => {
+ toast.success("Invoice approved successfully");
+ queryClient.setQueryData(["admin-invoices-pending", jwt], (oldData: any) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page: any) => ({
+ ...page,
+ invoices: page.invoices.filter((inv: AdminInvoiceRow) => inv.invoiceId !== invoiceId),
+ })),
+ };
+ });
+ },
+ onError: (err: any) => {
+ toast.error(err?.message || "Failed to approve invoice");
+ },
+ });
+
+ const rejectMutation = useMutation({
+ mutationFn: ({ invoiceId, reason }: { invoiceId: string; reason: string }) =>
+ rejectAdminInvoice(invoiceId, reason, jwt ?? undefined),
+ onSuccess: (_, variables) => {
+ toast.success("Invoice rejected successfully");
+ setRejectingInvoiceId(null);
+ setRejectionReason("");
+ queryClient.setQueryData(["admin-invoices-pending", jwt], (oldData: any) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page: any) => ({
+ ...page,
+ invoices: page.invoices.filter(
+ (inv: AdminInvoiceRow) => inv.invoiceId !== variables.invoiceId
+ ),
+ })),
+ };
+ });
+ },
+ onError: (err: any) => {
+ toast.error(err?.message || "Failed to reject invoice");
+ },
+ });
+
+ const allInvoices = data?.pages.flatMap((p) => p.invoices) ?? [];
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ Error loading pending invoices. Please try again.
+
+ );
+ }
+
+ return (
+
+
+
+ Pending Invoice Submissions
+
+
+ {allInvoices.length === 0 ? (
+
+ No pending invoices to review.
+
+ ) : (
+
+ )}
+
+ {hasNextPage && (
+
+
+
+ )}
+
+
+
+ {/* Document Modal */}
+ {selectedDocUrl && (
+
+
+
+
Document Preview
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/InvestorPortfolio.tsx b/components/dashboard/InvestorPortfolio.tsx
index 8f12527..57118aa 100644
--- a/components/dashboard/InvestorPortfolio.tsx
+++ b/components/dashboard/InvestorPortfolio.tsx
@@ -1,10 +1,12 @@
"use client";
+import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { PositionCard } from "@/components/dashboard/PositionCard";
+import { PayoutHistoryTable } from "@/components/dashboard/PayoutHistoryTable";
import { usePortfolio } from "@/hooks/usePortfolio";
import { calculateActiveTotal } from "@/lib/portfolio";
@@ -23,7 +25,8 @@ function PositionRowSkeleton() {
}
export function InvestorPortfolio() {
- const { data, isLoading, isFetching } = usePortfolio();
+ const [activeTab, setActiveTab] = useState<"positions" | "payouts">("positions");
+ const { data, isLoading } = usePortfolio();
// Show full skeleton only on initial load
if (isLoading || !data) {
@@ -71,26 +74,60 @@ export function InvestorPortfolio() {
-
-
Active Positions
- {positions.length === 0 ? (
-
-
- No active investments yet — browse the marketplace to get started
-
-
-
- ) : (
- positions.map((position) => (
-
- ))
- )}
+
+
+
+
+ {activeTab === "positions" ? (
+
+
Active Positions
+ {positions.length === 0 ? (
+
+
+ No active investments yet — browse the marketplace to get started
+
+
+
+ ) : (
+ positions.map((position) => (
+
+ ))
+ )}
+
+ ) : (
+
+ )}
);
}
diff --git a/components/dashboard/OnboardingChecklist.tsx b/components/dashboard/OnboardingChecklist.tsx
new file mode 100644
index 0000000..c3b94d0
--- /dev/null
+++ b/components/dashboard/OnboardingChecklist.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { CheckCircle2, Circle, X } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+
+export interface OnboardingUserData {
+ kycStatus: "pending" | "approved" | "rejected" | null;
+ displayName: string | null;
+ invoiceCount: number;
+}
+
+export interface OnboardingChecklistProps {
+ data: OnboardingUserData;
+}
+
+const STORAGE_KEY = "onboarding_checklist_dismissed";
+
+export function OnboardingChecklist({ data }: OnboardingChecklistProps) {
+ const [isDismissed, setIsDismissed] = useState(false);
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ const dismissed = localStorage.getItem(STORAGE_KEY);
+ if (dismissed === "true") {
+ setIsDismissed(true);
+ }
+ }
+ }, []);
+
+ const step1Complete = data.kycStatus === "pending" || data.kycStatus === "approved";
+ const step2Complete = data.displayName !== null && data.displayName.trim() !== "";
+ const step3Complete = data.invoiceCount >= 1;
+
+ const allComplete = step1Complete && step2Complete && step3Complete;
+
+ if (isDismissed || allComplete) {
+ return null;
+ }
+
+ const handleDismiss = () => {
+ setIsDismissed(true);
+ if (typeof window !== "undefined") {
+ localStorage.setItem(STORAGE_KEY, "true");
+ }
+ };
+
+ return (
+
+
+ Getting Started Checklist
+
+
+
+
+ {step1Complete ? (
+
+ ) : (
+
+ )}
+
+ Complete Identity Verification (KYC)
+
+
+
+
+ {step2Complete ? (
+
+ ) : (
+
+ )}
+
+ Set your Display Name
+
+
+
+
+ {step3Complete ? (
+
+ ) : (
+
+ )}
+
+ Create your first invoice
+
+
+
+
+ );
+}
diff --git a/components/dashboard/PayoutHistoryTable.tsx b/components/dashboard/PayoutHistoryTable.tsx
new file mode 100644
index 0000000..4489fda
--- /dev/null
+++ b/components/dashboard/PayoutHistoryTable.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { fetchInvestorPayouts, PayoutRecord } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export function PayoutHistoryTable() {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isError,
+ } = useInfiniteQuery({
+ queryKey: ["investor-payouts"],
+ queryFn: ({ pageParam }) => fetchInvestorPayouts(pageParam as string | undefined),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined,
+ });
+
+ const allPayouts = data?.pages.flatMap((p) => p.payouts) ?? [];
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ Failed to load payout history.
+
+ );
+ }
+
+ if (allPayouts.length === 0) {
+ return (
+
+ No payouts yet
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ | Invoice ID |
+ Seller Name |
+ Amount Invested |
+ Amount Received |
+ Yield |
+ Settled At |
+
+
+
+ {allPayouts.map((row, idx) => {
+ const isShortfall = row.amountReceived < row.amountInvested;
+ return (
+
+ | {row.invoiceId} |
+ {row.sellerName} |
+ {row.amountInvested.toLocaleString()} XLM |
+ {row.amountReceived.toLocaleString()} XLM |
+ {row.yield}% |
+
+ {new Date(row.settledAt).toLocaleDateString()}
+ |
+
+ );
+ })}
+
+
+
+
+ {hasNextPage && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/__tests__/OnboardingChecklist.test.tsx b/components/dashboard/__tests__/OnboardingChecklist.test.tsx
new file mode 100644
index 0000000..f35d385
--- /dev/null
+++ b/components/dashboard/__tests__/OnboardingChecklist.test.tsx
@@ -0,0 +1,78 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { OnboardingChecklist, OnboardingUserData } from "../OnboardingChecklist";
+
+describe("OnboardingChecklist", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ const baseData: OnboardingUserData = {
+ kycStatus: null,
+ displayName: null,
+ invoiceCount: 0,
+ };
+
+ it("Step 1 is incomplete when kycStatus is null and complete when kycStatus is pending or approved", () => {
+ const { rerender } = render();
+ expect(screen.getByTestId("step-1-incomplete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-1-complete")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("step-1-complete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-1-incomplete")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("step-1-complete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-1-incomplete")).not.toBeInTheDocument();
+ });
+
+ it("Step 2 is incomplete when displayName is null and complete when displayName is set", () => {
+ const { rerender } = render();
+ expect(screen.getByTestId("step-2-incomplete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-2-complete")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("step-2-complete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-2-incomplete")).not.toBeInTheDocument();
+ });
+
+ it("Step 3 is incomplete when invoiceCount is 0 and complete when invoiceCount is at least 1", () => {
+ const { rerender } = render();
+ expect(screen.getByTestId("step-3-incomplete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-3-complete")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("step-3-complete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-3-incomplete")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("step-3-complete")).toBeInTheDocument();
+ expect(screen.queryByTestId("step-3-incomplete")).not.toBeInTheDocument();
+ });
+
+ it("checklist is hidden when all three steps are complete", () => {
+ const completeData: OnboardingUserData = {
+ kycStatus: "approved",
+ displayName: "Alice",
+ invoiceCount: 1,
+ };
+ render();
+ expect(screen.queryByTestId("onboarding-checklist")).not.toBeInTheDocument();
+ });
+
+ it("dismissal writes to localStorage and hides the checklist", () => {
+ render();
+ expect(screen.getByTestId("onboarding-checklist")).toBeInTheDocument();
+
+ const dismissBtn = screen.getByTestId("dismiss-checklist-btn");
+ fireEvent.click(dismissBtn);
+
+ expect(screen.queryByTestId("onboarding-checklist")).not.toBeInTheDocument();
+ expect(localStorage.getItem("onboarding_checklist_dismissed")).toBe("true");
+ });
+});
diff --git a/components/dashboard/__tests__/PayoutHistoryTable.test.tsx b/components/dashboard/__tests__/PayoutHistoryTable.test.tsx
new file mode 100644
index 0000000..8571865
--- /dev/null
+++ b/components/dashboard/__tests__/PayoutHistoryTable.test.tsx
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+import { InvestorPortfolio } from "../InvestorPortfolio";
+import * as api from "@/lib/api";
+import * as usePortfolioModule from "@/hooks/usePortfolio";
+
+vi.mock("@/lib/api");
+vi.mock("@/hooks/usePortfolio");
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+ {children}
+ );
+ };
+}
+
+const mockPayouts = [
+ {
+ invoiceId: "inv-201",
+ sellerName: "Stellar Tech",
+ amountInvested: 1000,
+ amountReceived: 1100,
+ yield: 10,
+ settledAt: "2026-07-01T12:00:00.000Z",
+ },
+ {
+ invoiceId: "inv-202",
+ sellerName: "Risky Business",
+ amountInvested: 2000,
+ amountReceived: 1500, // Shortfall
+ yield: 5,
+ settledAt: "2026-07-15T12:00:00.000Z",
+ },
+];
+
+describe("Investor Dashboard Payout History", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(usePortfolioModule, "usePortfolio").mockReturnValue({
+ data: {
+ positions: [
+ {
+ invoice_id: "inv-1",
+ title: "Active Inv 1",
+ committed_amount: 5000,
+ target_amount: 10000,
+ status: "open",
+ due_date: "2026-12-31",
+ } as any,
+ ],
+ },
+ isLoading: false,
+ } as any);
+ });
+
+ it("Payout history tab renders on the investor dashboard", () => {
+ render(, { wrapper: createWrapper() });
+ const tab = screen.getByTestId("tab-payout-history");
+ expect(tab).toBeInTheDocument();
+ });
+
+ it("All required columns displayed correctly", async () => {
+ vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({
+ payouts: [mockPayouts[0]],
+ has_more: false,
+ next_cursor: null,
+ });
+
+ render(, { wrapper: createWrapper() });
+ fireEvent.click(screen.getByTestId("tab-payout-history"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("payout-history-table")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Invoice ID")).toBeInTheDocument();
+ expect(screen.getByText("Seller Name")).toBeInTheDocument();
+ expect(screen.getByText("Amount Invested")).toBeInTheDocument();
+ expect(screen.getByText("Amount Received")).toBeInTheDocument();
+ expect(screen.getByText("Yield")).toBeInTheDocument();
+ expect(screen.getByText("Settled At")).toBeInTheDocument();
+
+ expect(screen.getByText("inv-201")).toBeInTheDocument();
+ expect(screen.getByText("Stellar Tech")).toBeInTheDocument();
+ expect(screen.getByText("1,000 XLM")).toBeInTheDocument();
+ expect(screen.getByText("1,100 XLM")).toBeInTheDocument();
+ expect(screen.getByText("10%")).toBeInTheDocument();
+ });
+
+ it("Shortfall rows highlighted in amber", async () => {
+ vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({
+ payouts: mockPayouts,
+ has_more: false,
+ next_cursor: null,
+ });
+
+ render(, { wrapper: createWrapper() });
+ fireEvent.click(screen.getByTestId("tab-payout-history"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("payout-row-inv-202")).toBeInTheDocument();
+ });
+
+ const normalRow = screen.getByTestId("payout-row-inv-201");
+ const shortfallRow = screen.getByTestId("payout-row-inv-202");
+
+ expect(shortfallRow).toHaveClass("bg-amber-500/15");
+ expect(normalRow).not.toHaveClass("bg-amber-500/15");
+ });
+
+ it("Cursor pagination loads next page correctly", async () => {
+ vi.mocked(api.fetchInvestorPayouts)
+ .mockResolvedValueOnce({
+ payouts: [mockPayouts[0]],
+ has_more: true,
+ next_cursor: "cursor-page-2",
+ })
+ .mockResolvedValueOnce({
+ payouts: [mockPayouts[1]],
+ has_more: false,
+ next_cursor: null,
+ });
+
+ render(, { wrapper: createWrapper() });
+ fireEvent.click(screen.getByTestId("tab-payout-history"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("payouts-load-next")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTestId("payouts-load-next"));
+
+ await waitFor(() => {
+ expect(api.fetchInvestorPayouts).toHaveBeenCalledWith("cursor-page-2");
+ expect(screen.getByTestId("payout-row-inv-202")).toBeInTheDocument();
+ });
+ });
+
+ it("Empty state shown when no payouts exist", async () => {
+ vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({
+ payouts: [],
+ has_more: false,
+ next_cursor: null,
+ });
+
+ render(, { wrapper: createWrapper() });
+ fireEvent.click(screen.getByTestId("tab-payout-history"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("empty-payouts")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("No payouts yet")).toBeInTheDocument();
+ });
+});
diff --git a/components/dashboard/index.ts b/components/dashboard/index.ts
index 4e89f96..008739c 100644
--- a/components/dashboard/index.ts
+++ b/components/dashboard/index.ts
@@ -4,6 +4,6 @@ export { NotificationPreferences } from "./NotificationPreferences";
export { KycSubmissionForm } from "./KycSubmissionForm";
export { SellerDashboard } from "./SellerDashboard";
export { InvestorPortfolio } from "./InvestorPortfolio";
-export { NotificationList } from "./NotificationList";
-
+export { OnboardingChecklist } from "./OnboardingChecklist";
+export { PayoutHistoryTable } from "./PayoutHistoryTable";
diff --git a/components/invoices/__tests__/PublishInvoiceForm.test.tsx b/components/invoices/__tests__/PublishInvoiceForm.test.tsx
index bafe70a..3256add 100644
--- a/components/invoices/__tests__/PublishInvoiceForm.test.tsx
+++ b/components/invoices/__tests__/PublishInvoiceForm.test.tsx
@@ -26,7 +26,7 @@ describe("PublishInvoiceForm field preservation", () => {
const descriptionInput = screen.getByLabelText("Description");
await user.type(descriptionInput, "Test Description");
- const faceValueInput = screen.getByDisplayValue(/^$/);
+ const faceValueInput = screen.getByLabelText("Face Value (XLM)");
await user.type(faceValueInput, "1000");
const deadlineInput = screen.getByLabelText("Funding Deadline");
@@ -36,7 +36,7 @@ describe("PublishInvoiceForm field preservation", () => {
await user.click(nextButton);
await waitFor(() => {
- expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument();
+ expect(screen.getByLabelText("Upload Document")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /Back/i });
@@ -58,7 +58,7 @@ describe("PublishInvoiceForm field preservation", () => {
const descriptionInput = screen.getByLabelText("Description");
await user.type(descriptionInput, "Test Description");
- const faceValueInput = screen.getByDisplayValue(/^$/);
+ const faceValueInput = screen.getByLabelText("Face Value (XLM)");
await user.type(faceValueInput, "1000");
const deadlineInput = screen.getByLabelText("Funding Deadline");
@@ -68,7 +68,7 @@ describe("PublishInvoiceForm field preservation", () => {
await user.click(nextButton);
await waitFor(() => {
- expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument();
+ expect(screen.getByLabelText("Upload Document")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /Back/i });
@@ -90,7 +90,7 @@ describe("PublishInvoiceForm field preservation", () => {
const descriptionInput = screen.getByLabelText("Description");
await user.type(descriptionInput, "Test Description");
- const faceValueInput = screen.getByDisplayValue(/^$/);
+ const faceValueInput = screen.getByLabelText("Face Value (XLM)");
await user.type(faceValueInput, "1000");
const deadlineInput = screen.getByLabelText("Funding Deadline");
@@ -100,7 +100,7 @@ describe("PublishInvoiceForm field preservation", () => {
await user.click(nextButton);
await waitFor(() => {
- expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument();
+ expect(screen.getByLabelText("Upload Document")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /Back/i });
@@ -122,7 +122,7 @@ describe("PublishInvoiceForm field preservation", () => {
const descriptionInput = screen.getByLabelText("Description");
await user.type(descriptionInput, "Test Description");
- const faceValueInput = screen.getByDisplayValue(/^$/);
+ const faceValueInput = screen.getByLabelText("Face Value (XLM)");
await user.type(faceValueInput, "1000");
const deadlineInput = screen.getByLabelText("Funding Deadline");
@@ -132,7 +132,7 @@ describe("PublishInvoiceForm field preservation", () => {
await user.click(nextButton);
await waitFor(() => {
- expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument();
+ expect(screen.getByLabelText("Upload Document")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /Back/i });
@@ -154,7 +154,7 @@ describe("PublishInvoiceForm field preservation", () => {
const descriptionInput = screen.getByLabelText("Description");
await user.type(descriptionInput, "Test Description");
- const faceValueInput = screen.getByDisplayValue(/^$/);
+ const faceValueInput = screen.getByLabelText("Face Value (XLM)");
await user.type(faceValueInput, "1000");
const deadlineInput = screen.getByLabelText("Funding Deadline");
@@ -164,7 +164,7 @@ describe("PublishInvoiceForm field preservation", () => {
await user.click(nextButton);
await waitFor(() => {
- expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument();
+ expect(screen.getByLabelText("Upload Document")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /Back/i });
diff --git a/components/marketplace/FilterPanel.tsx b/components/marketplace/FilterPanel.tsx
new file mode 100644
index 0000000..91a8d10
--- /dev/null
+++ b/components/marketplace/FilterPanel.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useState } from "react";
+import { Filter, X, ChevronDown, ChevronUp } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+
+export type FundingStatus = "open" | "funded" | "settled" | "expired";
+
+export interface MarketplaceFilterState {
+ statuses: FundingStatus[];
+ minYield: number;
+ fromDate: string;
+ toDate: string;
+}
+
+interface FilterPanelProps {
+ filters: MarketplaceFilterState;
+ onFilterChange: (newFilters: MarketplaceFilterState) => void;
+ onClear: () => void;
+}
+
+const STATUS_OPTIONS: { label: string; value: FundingStatus }[] = [
+ { label: "Open", value: "open" },
+ { label: "Funded", value: "funded" },
+ { label: "Settled", value: "settled" },
+ { label: "Expired", value: "expired" },
+];
+
+export function FilterPanel({ filters, onFilterChange, onClear }: FilterPanelProps) {
+ const [isOpen, setIsOpen] = useState(true);
+
+ const handleStatusToggle = (status: FundingStatus) => {
+ const exists = filters.statuses.includes(status);
+ const newStatuses = exists
+ ? filters.statuses.filter((s) => s !== status)
+ : [...filters.statuses, status];
+ onFilterChange({ ...filters, statuses: newStatuses });
+ };
+
+ const activeCount =
+ filters.statuses.length +
+ (filters.minYield > 0 ? 1 : 0) +
+ (filters.fromDate ? 1 : 0) +
+ (filters.toDate ? 1 : 0);
+
+ return (
+
+
+
+
+ {activeCount > 0 && (
+
+ )}
+
+
+ {isOpen && (
+
+ {/* Funding Status Multi-select */}
+
+
+
+ {STATUS_OPTIONS.map((opt) => {
+ const checked = filters.statuses.includes(opt.value);
+ return (
+
+ );
+ })}
+
+
+
+ {/* Minimum Yield Slider */}
+
+
+
+ {filters.minYield}%
+
+
+ onFilterChange({ ...filters, minYield: Number(e.target.value) })
+ }
+ className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
+ data-testid="min-yield-slider"
+ />
+
+ 0%
+ 30%
+
+
+
+ {/* Due Date Range Picker */}
+
+
+ )}
+
+ );
+}
diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts
index 0bef154..d25e46a 100644
--- a/components/marketplace/index.ts
+++ b/components/marketplace/index.ts
@@ -1,5 +1,5 @@
export { MarketplaceFilterBar } from "./filter-bar";
export { InvoiceCard } from "./invoice-card";
export { CountdownTimer, isExpired } from "./countdown-timer";
-export { TopInvestorsLeaderboard } from "./TopInvestorsLeaderboard";
-
+export { FilterPanel } from "./FilterPanel";
+export type { MarketplaceFilterState, FundingStatus } from "./FilterPanel";
diff --git a/lib/api/index.ts b/lib/api/index.ts
index 4d06bfc..cf8d6b7 100644
--- a/lib/api/index.ts
+++ b/lib/api/index.ts
@@ -7,6 +7,7 @@ export interface Invoice {
investor_count: number;
status: "open" | "funded" | "settled" | "rejected" | "draft";
due_date: string;
+ yield_percentage?: number;
rejection_reason?: string;
has_more: boolean;
next_cursor: string | null;
@@ -28,9 +29,17 @@ import type { InvestmentPosition } from "@/lib/portfolio";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api";
-export async function fetchInvoices(cursor?: string): Promise {
+export async function fetchInvoices(
+ cursor?: string,
+ paramsObj?: Record
+): Promise {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
+ if (paramsObj) {
+ Object.entries(paramsObj).forEach(([k, v]) => {
+ if (v) params.set(k, v);
+ });
+ }
const res = await fetch(`${API_BASE}/invoices?${params}`);
if (!res.ok) throw new Error("Failed to fetch invoices");
return res.json();
@@ -157,98 +166,91 @@ export async function updateNotificationPreference(
return res.json();
}
-export interface LeaderboardInvestor {
- address: string;
- total_committed: number;
- invoice_count: number;
+export interface AdminInvoiceRow {
+ invoiceId: string;
+ sellerName: string;
+ faceValue: number;
+ submittedAt: string;
+ documentUrl?: string;
+ status: string;
}
-export async function fetchLeaderboard(): Promise {
- const res = await fetch(`${API_BASE}/leaderboard`);
- if (!res.ok) throw new Error("Failed to fetch leaderboard");
- return res.json();
+export interface AdminInvoicesResponse {
+ invoices: AdminInvoiceRow[];
+ has_more: boolean;
+ next_cursor: string | null;
}
-export interface UpdateInvoiceInput {
- title?: string;
- description?: string;
- faceValue?: number;
- fundingDeadline?: string;
- documentCid?: string;
-}
+export async function fetchAdminInvoices(
+ status = "pending",
+ cursor?: string,
+ token?: string
+): Promise {
+ const params = new URLSearchParams({ status });
+ if (cursor) params.set("cursor", cursor);
+ const headers: Record = {};
+ if (token) headers["Authorization"] = `Bearer ${token}`;
-export async function updateInvoice(
- id: string,
- input: UpdateInvoiceInput
-): Promise {
- const res = await fetch(`${API_BASE}/invoices/${id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
- if (!res.ok) throw new Error("Failed to update invoice");
+ const res = await fetch(`${API_BASE}/admin/invoices?${params}`, { headers });
+ if (!res.ok) throw new Error("Failed to fetch admin invoices");
return res.json();
}
-export interface PendingInvoice {
- id: string;
- title: string;
- seller: string;
- face_value: number;
- submission_date: string;
- status: "pending_review";
-}
-
-export async function fetchPendingInvoices(): Promise {
- const res = await fetch(`${API_BASE}/admin/invoices/pending`);
- if (!res.ok) throw new Error("Failed to fetch pending invoices");
- return res.json();
-}
+export async function approveAdminInvoice(
+ invoiceId: string,
+ token?: string
+): Promise<{ success: boolean }> {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (token) headers["Authorization"] = `Bearer ${token}`;
-export async function approveInvoice(id: string): Promise<{ success: boolean }> {
- const res = await fetch(`${API_BASE}/admin/invoices/${id}/approve`, {
+ const res = await fetch(`${API_BASE}/admin/invoices/${invoiceId}/approve`, {
method: "POST",
+ headers,
});
if (!res.ok) throw new Error("Failed to approve invoice");
return res.json();
}
-export async function rejectInvoice(id: string, reason: string): Promise<{ success: boolean }> {
- const res = await fetch(`${API_BASE}/admin/invoices/${id}/reject`, {
+export async function rejectAdminInvoice(
+ invoiceId: string,
+ reason: string,
+ token?: string
+): Promise<{ success: boolean }> {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+
+ const res = await fetch(`${API_BASE}/admin/invoices/${invoiceId}/reject`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers,
body: JSON.stringify({ reason }),
});
if (!res.ok) throw new Error("Failed to reject invoice");
return res.json();
}
-export interface NotificationItem {
- id: string;
- title?: string;
- message: string;
- read: boolean;
- createdAt?: string;
- created_at?: string;
+export interface PayoutRecord {
+ invoiceId: string;
+ sellerName: string;
+ amountInvested: number;
+ amountReceived: number;
+ yield: number;
+ settledAt: 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();
+export interface PayoutsResponse {
+ payouts: PayoutRecord[];
+ has_more: boolean;
+ next_cursor: string | null;
}
-export async function fetchUnreadCount(): Promise<{ count: number }> {
- const res = await fetch(`${API_BASE}/notifications/unread-count`);
- if (!res.ok) throw new Error("Failed to fetch unread count");
- return res.json();
-}
+export async function fetchInvestorPayouts(
+ cursor?: string
+): Promise {
+ const params = new URLSearchParams();
+ if (cursor) params.set("cursor", cursor);
-export async function markNotificationAsRead(id: string): Promise<{ success: boolean }> {
- const res = await fetch(`${API_BASE}/notifications/${id}/read`, {
- method: "PATCH",
- });
- if (!res.ok) throw new Error("Failed to mark notification as read");
+ const res = await fetch(`${API_BASE}/investor/payouts?${params}`);
+ if (!res.ok) throw new Error("Failed to fetch payout history");
return res.json();
}
diff --git a/package-lock.json b/package-lock.json
index ac7f64c..1af5b1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.6",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -2853,6 +2854,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
+ "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
diff --git a/package.json b/package.json
index d8fb7d2..525b732 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.6",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",