diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9509cf2..3dde108 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,6 +3,11 @@ import { useState } from "react"; import { AdminInvoiceReview } from "@/components/admin/AdminInvoiceReview"; import { AuditLogViewer } from "@/components/admin/AuditLogViewer"; +import { TimelockProposalsPanel } from "@/components/admin/TimelockProposalsPanel"; + +export default function AdminPage() { + const [activeTab, setActiveTab] = useState< + "invoices" | "audit-log" | "timelock" import { TradingControlsPanel } from "@/components/admin/TradingControlsPanel"; export default function AdminPage() { @@ -39,6 +44,14 @@ export default function AdminPage() { + + + + ); +} + +export function TimelockProposalsPanel() { + const { jwt } = useAuth(); + const proposalsQuery = useTimelockProposals(jwt); + const executeMutation = useExecuteTimelockProposalMutation(); + const cancelMutation = useCancelTimelockProposalMutation(); + const [confirmingId, setConfirmingId] = useState(null); + + const proposals = proposalsQuery.data?.proposals ?? []; + const pending = proposals.filter((proposal) => proposal.status === "pending"); + const completed = proposals.filter( + (proposal) => proposal.status === "executed" + ); + + const now = useNow(pending.length > 0); + + const handleExecute = (proposal: TimelockProposal) => { + executeMutation.mutate({ proposalId: proposal.id, token: jwt }); + }; + + const handleCancelConfirmed = (proposal: TimelockProposal) => { + setConfirmingId(null); + cancelMutation.mutate({ proposalId: proposal.id, token: jwt }); + }; + + if (proposalsQuery.isLoading) { + return ( + + +

Timelock Proposals

+
+ + + + +
+ ); + } + + const confirmingProposal = + pending.find((proposal) => proposal.id === confirmingId) ?? null; + + return ( + + +

Timelock Proposals

+

+ Pending configuration changes and their execution windows. +

+
+ +
+

Pending

+ {pending.length === 0 ? ( +

+ No pending proposals +

+ ) : ( + pending.map((proposal) => ( + setConfirmingId(target.id)} + isExecuting={ + executeMutation.isPending && + executeMutation.variables?.proposalId === proposal.id + } + isCancelling={ + cancelMutation.isPending && + cancelMutation.variables?.proposalId === proposal.id + } + /> + )) + )} +
+ +
+

Completed

+ {completed.length === 0 ? ( +

+ No completed proposals +

+ ) : ( +
+ {completed.map((proposal) => ( +
+ {proposal.changeType} + + Executed {formatDateTime(proposal.executedAt ?? "")} + +
+ ))} +
+ )} +
+
+ + {confirmingProposal && ( +
+ + +

Cancel proposal?

+
+ +

+ This permanently cancels the{" "} + + {confirmingProposal.changeType} + {" "} + proposal. This cannot be undone. +

+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/components/admin/__tests__/TimelockProposalsPanel.test.tsx b/components/admin/__tests__/TimelockProposalsPanel.test.tsx new file mode 100644 index 0000000..2447f7d --- /dev/null +++ b/components/admin/__tests__/TimelockProposalsPanel.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { TimelockProposalsPanel } from "../TimelockProposalsPanel"; +import * as api from "@/lib/api"; + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ address: "GADMINTEST", jwt: "jwt-token" }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +function pendingProposal( + overrides: Partial = {} +): api.TimelockProposal { + return { + id: "prop-1", + changeType: "update_protocol_fee", + payload: { feeBps: 250 }, + proposedAt: "2026-08-01T10:00:00.000Z", + executionNotBefore: new Date(Date.now() + 86_400_000).toISOString(), + status: "pending", + executedAt: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("TimelockProposalsPanel", () => { + it("lists pending proposals with their change type, payload and window fields", async () => { + vi.spyOn(api, "fetchTimelockProposals").mockResolvedValue({ + proposals: [pendingProposal()], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-proposal-prop-1")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("timelock-change-type-prop-1")).toHaveTextContent( + "update_protocol_fee" + ); + expect(screen.getByTestId("timelock-payload-prop-1")).toHaveTextContent( + "feeBps: 250" + ); + expect(screen.getByTestId("timelock-proposed-at-prop-1")).toHaveTextContent( + "Proposed" + ); + expect(screen.getByTestId("timelock-not-before-prop-1")).toHaveTextContent( + "Executable from" + ); + }); + + it("shows a countdown and disables Execute before the window opens", async () => { + vi.spyOn(api, "fetchTimelockProposals").mockResolvedValue({ + proposals: [pendingProposal()], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-countdown-prop-1")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("timelock-countdown-prop-1")).toHaveTextContent( + "Executable in" + ); + expect(screen.getByTestId("timelock-execute-prop-1")).toBeDisabled(); + }); + + it("enables Execute once the execution window has opened", async () => { + vi.spyOn(api, "fetchTimelockProposals").mockResolvedValue({ + proposals: [ + pendingProposal({ + executionNotBefore: new Date(Date.now() - 60_000).toISOString(), + }), + ], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-ready-prop-1")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("timelock-execute-prop-1")).toBeEnabled(); + expect( + screen.queryByTestId("timelock-countdown-prop-1") + ).not.toBeInTheDocument(); + }); + + it("executes an eligible proposal", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchTimelockProposals").mockResolvedValue({ + proposals: [ + pendingProposal({ + executionNotBefore: new Date(Date.now() - 60_000).toISOString(), + }), + ], + }); + const executeSpy = vi + .spyOn(api, "executeTimelockProposal") + .mockResolvedValue({ success: true }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-execute-prop-1")).toBeEnabled(); + }); + await user.click(screen.getByTestId("timelock-execute-prop-1")); + + await waitFor(() => { + expect(executeSpy).toHaveBeenCalledWith("prop-1", "jwt-token"); + }); + }); + + it("removes a proposal from the list after cancel is confirmed", async () => { + const user = userEvent.setup(); + const fetchSpy = vi + .spyOn(api, "fetchTimelockProposals") + .mockResolvedValueOnce({ proposals: [pendingProposal()] }) + .mockResolvedValue({ proposals: [] }); + const cancelSpy = vi + .spyOn(api, "cancelTimelockProposal") + .mockResolvedValue({ success: true }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-cancel-prop-1")).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("timelock-cancel-prop-1")); + + // Cancel requires confirmation before it fires. + expect(cancelSpy).not.toHaveBeenCalled(); + await user.click(screen.getByTestId("timelock-cancel-confirm")); + + await waitFor(() => { + expect(cancelSpy).toHaveBeenCalledWith("prop-1", "jwt-token"); + }); + + await waitFor(() => { + expect( + screen.queryByTestId("timelock-proposal-prop-1") + ).not.toBeInTheDocument(); + }); + expect(fetchSpy.mock.calls.length).toBeGreaterThan(1); + }); + + it("lists executed proposals in a separate Completed section", async () => { + vi.spyOn(api, "fetchTimelockProposals").mockResolvedValue({ + proposals: [ + pendingProposal(), + pendingProposal({ + id: "prop-2", + changeType: "rotate_admin", + status: "executed", + executedAt: "2026-08-10T12:00:00.000Z", + }), + ], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("timelock-completed-prop-2")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("timelock-completed-prop-2")).toHaveTextContent( + "rotate_admin" + ); + // The executed proposal must not appear as a pending row. + expect( + screen.queryByTestId("timelock-proposal-prop-2") + ).not.toBeInTheDocument(); + expect(screen.getByTestId("timelock-proposal-prop-1")).toBeInTheDocument(); + }); +}); diff --git a/components/keys/CreatorKeyDetail.tsx b/components/keys/CreatorKeyDetail.tsx index 2d3c4eb..85eae1f 100644 --- a/components/keys/CreatorKeyDetail.tsx +++ b/components/keys/CreatorKeyDetail.tsx @@ -9,6 +9,9 @@ import { Skeleton } from "@/components/ui/skeleton"; import { GovernanceTab } from "@/components/keys/GovernanceTab"; import { BuyKeyPanel } from "@/components/keys/BuyKeyPanel"; import { CreatorVestingSection } from "@/components/keys/CreatorVestingSection"; +import { CreatorRevenueSection } from "@/components/keys/CreatorRevenueSection"; +import { DistributeDividendsPanel } from "@/components/keys/DistributeDividendsPanel"; +import { SupplyCapSettings } from "@/components/keys/SupplyCapSettings"; import { WhitelistManager } from "@/components/keys/WhitelistManager"; import { useCreatorKey, useKeySupply } from "@/hooks/useCreatorKeys"; import { useAuth } from "@/hooks/useAuth"; @@ -37,6 +40,7 @@ function CreatorKeyDetailSkeleton() { export function CreatorKeyDetail({ keyId }: CreatorKeyDetailProps) { const [activeTab, setActiveTab] = useState< + "overview" | "governance" | "settings" "overview" | "governance" | "whitelist" >("overview"); const { jwt } = useAuth(); @@ -109,6 +113,14 @@ export function CreatorKeyDetail({ keyId }: CreatorKeyDetailProps) { + + + ); +} diff --git a/components/keys/SupplyCapSettings.tsx b/components/keys/SupplyCapSettings.tsx new file mode 100644 index 0000000..a90beff --- /dev/null +++ b/components/keys/SupplyCapSettings.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isApiConflictError } from "@/lib/api"; +import { useAuth } from "@/hooks/useAuth"; +import { useKeySupply, useUpdateSupplyCapMutation } from "@/hooks/useCreatorKeys"; + +interface SupplyCapSettingsProps { + keyId: string; +} + +function formatCount(value: number): string { + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +export function SupplyCapSettings({ keyId }: SupplyCapSettingsProps) { + const { jwt } = useAuth(); + const supplyQuery = useKeySupply(keyId, jwt); + const updateMutation = useUpdateSupplyCapMutation(keyId); + + const supply = supplyQuery.data; + const currentCap = supply?.supplyCap ?? null; + + const [draftCap, setDraftCap] = useState(null); + const [conflictError, setConflictError] = useState(null); + + const capValue = draftCap ?? (currentCap === null ? "" : String(currentCap)); + const circulatingSupply = supply?.circulatingSupply ?? 0; + const parsedCap = Number(capValue); + + const validationError = useMemo(() => { + if (capValue.trim() === "") { + return "Enter a supply cap"; + } + if (!Number.isFinite(parsedCap) || !Number.isInteger(parsedCap) || parsedCap <= 0) { + return "Supply cap must be a positive whole number"; + } + if (parsedCap < circulatingSupply) { + return `Supply cap cannot be below the circulating supply of ${formatCount( + circulatingSupply + )}`; + } + return null; + }, [capValue, circulatingSupply, parsedCap]); + + const isUnchanged = currentCap !== null && parsedCap === currentCap; + const canSave = + !validationError && !isUnchanged && !updateMutation.isPending; + + const handleSave = async () => { + if (!canSave) return; + setConflictError(null); + + try { + await updateMutation.mutateAsync({ supplyCap: parsedCap, token: jwt }); + setDraftCap(null); + } catch (error) { + if (isApiConflictError(error)) { + setConflictError(error.message); + return; + } + setConflictError( + error instanceof Error ? error.message : "Failed to update supply cap" + ); + } + }; + + if (supplyQuery.isLoading) { + return ( + + +

Supply Cap

+
+ + + + +
+ ); + } + + if (!supply) { + return null; + } + + return ( + + +

Supply Cap

+

+ Set the maximum number of keys that can ever be minted. +

+
+ +
+
+

Current cap

+

+ {currentCap === null ? "No cap set" : formatCount(currentCap)} +

+
+
+

Circulating

+

+ {formatCount(circulatingSupply)} +

+
+
+

Remaining mintable

+

+ {currentCap === null + ? "No cap set" + : formatCount(supply.remainingMintable)} +

+
+
+ +
+ + { + setDraftCap(event.target.value); + setConflictError(null); + }} + aria-invalid={Boolean(validationError)} + data-testid="supply-cap-input" + /> + {validationError && capValue.trim() !== "" && ( +

+ {validationError} +

+ )} +
+ + {conflictError && ( +

+ {conflictError} +

+ )} + + +
+
+ ); +} diff --git a/components/keys/__tests__/CreatorRevenueSection.test.tsx b/components/keys/__tests__/CreatorRevenueSection.test.tsx new file mode 100644 index 0000000..4b0b621 --- /dev/null +++ b/components/keys/__tests__/CreatorRevenueSection.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { CreatorRevenueSection } from "../CreatorRevenueSection"; +import * as api from "@/lib/api"; + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ address: "GCREATORTEST", jwt: "jwt-token" }), +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +function monthlyBreakdown(): api.MonthlyRevenue[] { + return [ + { month: "2025-09", royaltyEarned: 10 }, + { month: "2025-10", royaltyEarned: 20 }, + { month: "2025-11", royaltyEarned: 30 }, + { month: "2025-12", royaltyEarned: 40 }, + { month: "2026-01", royaltyEarned: 50 }, + { month: "2026-02", royaltyEarned: 60 }, + { month: "2026-03", royaltyEarned: 70 }, + { month: "2026-04", royaltyEarned: 80 }, + { month: "2026-05", royaltyEarned: 90 }, + { month: "2026-06", royaltyEarned: 100 }, + { month: "2026-07", royaltyEarned: 110 }, + { month: "2026-08", royaltyEarned: 120.5 }, + ]; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("CreatorRevenueSection", () => { + it("shows a loading skeleton while revenue data is fetching", () => { + vi.spyOn(api, "fetchCreatorRevenue").mockReturnValue( + new Promise(() => undefined) + ); + + renderWithClient(); + + expect(screen.getByTestId("creator-revenue-loading")).toBeInTheDocument(); + }); + + it("renders the three royalty stat cards", async () => { + vi.spyOn(api, "fetchCreatorRevenue").mockResolvedValue({ + totalRoyaltyEarned: 780.5, + buyRoyaltyEarned: 500.25, + sellRoyaltyEarned: 280.25, + tradeCount: 42, + monthlyBreakdown: monthlyBreakdown(), + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("creator-revenue-section")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("revenue-total")).toHaveTextContent("780.50 XLM"); + expect(screen.getByTestId("revenue-buy")).toHaveTextContent("500.25 XLM"); + expect(screen.getByTestId("revenue-sell")).toHaveTextContent("280.25 XLM"); + }); + + it("renders a 12-month chart with the correct monthly values", async () => { + vi.spyOn(api, "fetchCreatorRevenue").mockResolvedValue({ + totalRoyaltyEarned: 780.5, + buyRoyaltyEarned: 500.25, + sellRoyaltyEarned: 280.25, + tradeCount: 42, + monthlyBreakdown: monthlyBreakdown(), + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("revenue-chart")).toBeInTheDocument(); + }); + + const values = screen.getByTestId("revenue-chart-values"); + expect(values.children).toHaveLength(12); + expect(values).toHaveTextContent("Sep: 10.00 XLM"); + expect(values).toHaveTextContent("Aug: 120.50 XLM"); + }); + + it("only charts the most recent 12 months when more are returned", async () => { + vi.spyOn(api, "fetchCreatorRevenue").mockResolvedValue({ + totalRoyaltyEarned: 800, + buyRoyaltyEarned: 500, + sellRoyaltyEarned: 300, + tradeCount: 50, + monthlyBreakdown: [ + { month: "2025-07", royaltyEarned: 5 }, + { month: "2025-08", royaltyEarned: 7 }, + ...monthlyBreakdown(), + ], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("revenue-chart-values")).toBeInTheDocument(); + }); + + const values = screen.getByTestId("revenue-chart-values"); + expect(values.children).toHaveLength(12); + expect(values).not.toHaveTextContent("5.00 XLM"); + }); + + it("shows the empty state when tradeCount is zero", async () => { + vi.spyOn(api, "fetchCreatorRevenue").mockResolvedValue({ + totalRoyaltyEarned: 0, + buyRoyaltyEarned: 0, + sellRoyaltyEarned: 0, + tradeCount: 0, + monthlyBreakdown: [], + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("creator-revenue-empty")).toHaveTextContent( + "No revenue yet" + ); + }); + + expect(screen.queryByTestId("revenue-chart")).not.toBeInTheDocument(); + }); +}); diff --git a/components/keys/__tests__/DistributeDividendsPanel.test.tsx b/components/keys/__tests__/DistributeDividendsPanel.test.tsx new file mode 100644 index 0000000..b829bac --- /dev/null +++ b/components/keys/__tests__/DistributeDividendsPanel.test.tsx @@ -0,0 +1,176 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { DistributeDividendsPanel } from "../DistributeDividendsPanel"; +import * as api from "@/lib/api"; + +const toastSuccess = vi.fn(); + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ address: "GCREATORTEST", jwt: "jwt-token" }), +})); + +vi.mock("@/hooks/useStellarWallet", () => ({ + useStellarWallet: () => ({ address: "GCREATORTEST" }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: vi.fn(), + }, +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +beforeEach(() => { + vi.restoreAllMocks(); + toastSuccess.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("DistributeDividendsPanel", () => { + it("updates the per-key preview as the amount changes", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 200, + supplyCap: 1000, + remainingMintable: 800, + }); + + renderWithClient( + + ); + + await waitFor(() => { + expect(screen.getByTestId("dividend-holder-count")).toHaveTextContent( + "200 keys circulating" + ); + }); + + await user.type(screen.getByTestId("dividend-amount-input"), "100"); + + await waitFor(() => { + expect(screen.getByTestId("dividend-per-key")).toHaveTextContent( + "0.50 XLM" + ); + }); + }); + + it("displays the holder count below the preview", async () => { + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 200, + supplyCap: 1000, + remainingMintable: 800, + }); + + renderWithClient( + + ); + + await waitFor(() => { + expect(screen.getByTestId("dividend-holder-count")).toHaveTextContent( + "57 holders" + ); + }); + }); + + it("disables submit when the amount is zero", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 200, + supplyCap: 1000, + remainingMintable: 800, + }); + + renderWithClient( + + ); + + await waitFor(() => { + expect(screen.getByTestId("dividend-submit")).toBeDisabled(); + }); + + await user.type(screen.getByTestId("dividend-amount-input"), "0"); + expect(screen.getByTestId("dividend-submit")).toBeDisabled(); + }); + + it("disables submit when circulating supply is zero", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 0, + supplyCap: 1000, + remainingMintable: 1000, + }); + + renderWithClient( + + ); + + await waitFor(() => { + expect(screen.getByTestId("dividend-no-supply")).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId("dividend-amount-input"), "100"); + expect(screen.getByTestId("dividend-submit")).toBeDisabled(); + }); + + it("submits the distribution and shows totals in a success toast", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 200, + supplyCap: 1000, + remainingMintable: 800, + }); + const distributeSpy = vi + .spyOn(api, "distributeDividend") + .mockResolvedValue({ + totalDistributed: 100, + perKeyAmount: 0.5, + holderCount: 57, + }); + + renderWithClient( + + ); + + await waitFor(() => { + expect(screen.getByTestId("dividend-amount-input")).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId("dividend-amount-input"), "100"); + + await waitFor(() => { + expect(screen.getByTestId("dividend-submit")).toBeEnabled(); + }); + await user.click(screen.getByTestId("dividend-submit")); + + await waitFor(() => { + expect(distributeSpy).toHaveBeenCalledWith( + "key-1", + 100, + "GCREATORTEST", + "jwt-token" + ); + }); + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith( + "Distributed 100.00 XLM — 0.50 XLM per key" + ); + }); + }); +}); diff --git a/components/keys/__tests__/SupplyCapSettings.test.tsx b/components/keys/__tests__/SupplyCapSettings.test.tsx new file mode 100644 index 0000000..41bca21 --- /dev/null +++ b/components/keys/__tests__/SupplyCapSettings.test.tsx @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { SupplyCapSettings } from "../SupplyCapSettings"; +import * as api from "@/lib/api"; + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ address: "GCREATORTEST", jwt: "jwt-token" }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("SupplyCapSettings", () => { + it("displays cap, circulating supply and remaining mintable", async () => { + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 1000, + remainingMintable: 600, + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-settings")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("supply-cap-current")).toHaveTextContent("1,000"); + expect(screen.getByTestId("supply-cap-circulating")).toHaveTextContent("400"); + expect(screen.getByTestId("supply-cap-remaining")).toHaveTextContent("600"); + }); + + it("prefills the input with the current cap", async () => { + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 1000, + remainingMintable: 600, + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-input")).toHaveValue(1000); + }); + }); + + it("shows a 'No cap set' placeholder when supplyCap is null", async () => { + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 250, + supplyCap: null, + remainingMintable: 0, + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-settings")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("supply-cap-input")).toHaveAttribute( + "placeholder", + "No cap set" + ); + expect(screen.getByTestId("supply-cap-current")).toHaveTextContent( + "No cap set" + ); + }); + + it("disables Save when the input is below circulating supply", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 1000, + remainingMintable: 600, + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-input")).toHaveValue(1000); + }); + + const input = screen.getByTestId("supply-cap-input"); + await user.clear(input); + await user.type(input, "399"); + + expect(screen.getByTestId("supply-cap-save")).toBeDisabled(); + expect( + screen.getByTestId("supply-cap-validation-error") + ).toBeInTheDocument(); + }); + + it("enables Save and refreshes stats after a successful update", async () => { + const user = userEvent.setup(); + const fetchSpy = vi + .spyOn(api, "fetchKeySupply") + .mockResolvedValueOnce({ + circulatingSupply: 400, + supplyCap: 1000, + remainingMintable: 600, + }) + .mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 2000, + remainingMintable: 1600, + }); + + const updateSpy = vi.spyOn(api, "updateKeySupplyCap").mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 2000, + remainingMintable: 1600, + }); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-input")).toHaveValue(1000); + }); + + const input = screen.getByTestId("supply-cap-input"); + await user.clear(input); + await user.type(input, "2000"); + + const save = screen.getByTestId("supply-cap-save"); + expect(save).toBeEnabled(); + await user.click(save); + + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledWith("key-1", 2000, "jwt-token"); + }); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-current")).toHaveTextContent("2,000"); + }); + expect(fetchSpy.mock.calls.length).toBeGreaterThan(1); + }); + + it("shows a 409 conflict from the server as an inline error", async () => { + const user = userEvent.setup(); + vi.spyOn(api, "fetchKeySupply").mockResolvedValue({ + circulatingSupply: 400, + supplyCap: 1000, + remainingMintable: 600, + }); + + const conflict = new Error( + "Supply cap conflicts with the current circulating supply" + ) as api.ApiConflictError; + conflict.name = "ApiConflictError"; + conflict.isConflict = true; + vi.spyOn(api, "updateKeySupplyCap").mockRejectedValue(conflict); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-input")).toHaveValue(1000); + }); + + const input = screen.getByTestId("supply-cap-input"); + await user.clear(input); + await user.type(input, "2000"); + await user.click(screen.getByTestId("supply-cap-save")); + + await waitFor(() => { + expect(screen.getByTestId("supply-cap-conflict-error")).toHaveTextContent( + "Supply cap conflicts with the current circulating supply" + ); + }); + }); +}); diff --git a/hooks/useCreatorKeys.ts b/hooks/useCreatorKeys.ts index e81ffad..0bdddfc 100644 --- a/hooks/useCreatorKeys.ts +++ b/hooks/useCreatorKeys.ts @@ -12,6 +12,8 @@ import { fetchKeyProposals, fetchKeyWhitelistStatus, transferCreatorKey, + updateKeySupplyCap, + distributeDividend, type CreateProposalInput, } from "@/lib/api"; import { PORTFOLIO_QUERY_KEY } from "@/hooks/usePortfolio"; @@ -203,3 +205,47 @@ export function useTransferCreatorKeyMutation() { }, }); } + +export function useUpdateSupplyCapMutation(keyId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + supplyCap, + token, + }: { + supplyCap: number; + token?: string | null; + }) => updateKeySupplyCap(keyId, supplyCap, token ?? undefined), + onSuccess: () => { + toast.success("Supply cap updated"); + queryClient.invalidateQueries({ queryKey: keySupplyQueryKey(keyId) }); + queryClient.invalidateQueries({ queryKey: creatorKeyQueryKey(keyId) }); + }, + }); +} + +export function useDistributeDividendMutation(keyId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + amount, + walletAddress, + token, + }: { + amount: number; + walletAddress: string; + token?: string | null; + }) => distributeDividend(keyId, amount, walletAddress, token ?? undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: creatorKeyQueryKey(keyId) }); + queryClient.invalidateQueries({ queryKey: PORTFOLIO_QUERY_KEY }); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Dividend distribution failed" + ); + }, + }); +} diff --git a/hooks/useCreatorRevenue.ts b/hooks/useCreatorRevenue.ts new file mode 100644 index 0000000..752de09 --- /dev/null +++ b/hooks/useCreatorRevenue.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchCreatorRevenue } from "@/lib/api"; + +export const creatorRevenueQueryKey = (keyId: string) => + ["creator-key", keyId, "revenue"] as const; + +export function useCreatorRevenue(keyId: string, token?: string | null) { + return useQuery({ + queryKey: creatorRevenueQueryKey(keyId), + queryFn: () => fetchCreatorRevenue(keyId, token ?? undefined), + }); +} diff --git a/hooks/useTimelockProposals.ts b/hooks/useTimelockProposals.ts new file mode 100644 index 0000000..dca9f0c --- /dev/null +++ b/hooks/useTimelockProposals.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + cancelTimelockProposal, + executeTimelockProposal, + fetchTimelockProposals, +} from "@/lib/api"; + +export const timelockProposalsQueryKey = ["admin", "timelock", "proposals"] as const; + +export function useTimelockProposals(token?: string | null) { + return useQuery({ + queryKey: timelockProposalsQueryKey, + queryFn: () => fetchTimelockProposals(token ?? undefined), + }); +} + +export function useExecuteTimelockProposalMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + proposalId, + token, + }: { + proposalId: string; + token?: string | null; + }) => executeTimelockProposal(proposalId, token ?? undefined), + onSuccess: () => { + toast.success("Proposal executed"); + queryClient.invalidateQueries({ queryKey: timelockProposalsQueryKey }); + }, + onError: () => { + toast.error("Failed to execute proposal"); + }, + }); +} + +export function useCancelTimelockProposalMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + proposalId, + token, + }: { + proposalId: string; + token?: string | null; + }) => cancelTimelockProposal(proposalId, token ?? undefined), + onSuccess: () => { + toast.success("Proposal cancelled"); + queryClient.invalidateQueries({ queryKey: timelockProposalsQueryKey }); + }, + onError: () => { + toast.error("Failed to cancel proposal"); + }, + }); +} diff --git a/lib/api/index.ts b/lib/api/index.ts index 09c911b..f27bf18 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -689,6 +689,203 @@ export async function fetchAuditLog( }; } +export interface ApiConflictError extends Error { + isConflict: true; +} + +export function isApiConflictError(error: unknown): error is ApiConflictError { + return ( + error instanceof Error && + (error as Partial).isConflict === true + ); +} + +function conflictError(message: string): ApiConflictError { + const error = new Error(message) as ApiConflictError; + error.name = "ApiConflictError"; + error.isConflict = true; + return error; +} + +async function readErrorMessage(res: Response, fallback: string): Promise { + try { + const payload = await res.json(); + return payload?.message ?? payload?.error ?? fallback; + } catch { + return fallback; + } +} + +export async function updateKeySupplyCap( + keyId: string, + supplyCap: number, + token?: string +): Promise { + const res = await fetch(`${API_BASE}/keys/${keyId}/supply`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, + body: JSON.stringify({ supplyCap }), + }); + + if (res.status === 409) { + throw conflictError( + await readErrorMessage( + res, + "Supply cap conflicts with the current circulating supply" + ) + ); + } + if (!res.ok) throw new Error("Failed to update supply cap"); + + return normalizeKeySupply(await res.json()); +} + +export interface MonthlyRevenue { + month: string; + royaltyEarned: number; +} + +export interface CreatorRevenue { + totalRoyaltyEarned: number; + buyRoyaltyEarned: number; + sellRoyaltyEarned: number; + tradeCount: number; + monthlyBreakdown: MonthlyRevenue[]; +} + +function normalizeMonthlyRevenue(raw: any): MonthlyRevenue { + return { + month: raw.month ?? raw.period ?? "", + royaltyEarned: raw.royaltyEarned ?? raw.royalty_earned ?? 0, + }; +} + +function normalizeCreatorRevenue(raw: any): CreatorRevenue { + const monthly = + raw.monthlyBreakdown ?? raw.monthly_breakdown ?? raw.monthly ?? []; + + return { + totalRoyaltyEarned: + raw.totalRoyaltyEarned ?? raw.total_royalty_earned ?? 0, + buyRoyaltyEarned: raw.buyRoyaltyEarned ?? raw.buy_royalty_earned ?? 0, + sellRoyaltyEarned: raw.sellRoyaltyEarned ?? raw.sell_royalty_earned ?? 0, + tradeCount: raw.tradeCount ?? raw.trade_count ?? 0, + monthlyBreakdown: monthly.map(normalizeMonthlyRevenue), + }; +} + +export async function fetchCreatorRevenue( + keyId: string, + token?: string +): Promise { + const res = await fetch(`${API_BASE}/creator/${keyId}/revenue`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch creator revenue"); + return normalizeCreatorRevenue(await res.json()); +} + +export type TimelockProposalStatus = "pending" | "executed" | "cancelled"; + +export interface TimelockProposal { + id: string; + changeType: string; + payload: Record; + proposedAt: string; + executionNotBefore: string; + status: TimelockProposalStatus; + executedAt: string | null; +} + +export interface TimelockProposalsResponse { + proposals: TimelockProposal[]; +} + +function normalizeTimelockProposal(raw: any): TimelockProposal { + const rawStatus = raw.status ?? (raw.executedAt || raw.executed_at ? "executed" : "pending"); + + return { + id: raw.id, + changeType: raw.changeType ?? raw.change_type ?? "", + payload: raw.payload ?? {}, + proposedAt: raw.proposedAt ?? raw.proposed_at ?? "", + executionNotBefore: + raw.executionNotBefore ?? raw.execution_not_before ?? "", + status: rawStatus as TimelockProposalStatus, + executedAt: raw.executedAt ?? raw.executed_at ?? null, + }; +} + +export async function fetchTimelockProposals( + token?: string +): Promise { + const res = await fetch(`${API_BASE}/admin/timelock/proposals`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch timelock proposals"); + const payload = await res.json(); + const proposals = Array.isArray(payload) ? payload : payload.proposals ?? []; + return { proposals: proposals.map(normalizeTimelockProposal) }; +} + +export async function executeTimelockProposal( + proposalId: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch( + `${API_BASE}/admin/timelock/proposals/${proposalId}/execute`, + { + method: "POST", + headers: authHeaders(token), + } + ); + if (!res.ok) throw new Error("Failed to execute timelock proposal"); + return res.json(); +} + +export async function cancelTimelockProposal( + proposalId: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch( + `${API_BASE}/admin/timelock/proposals/${proposalId}/cancel`, + { + method: "POST", + headers: authHeaders(token), + } + ); + if (!res.ok) throw new Error("Failed to cancel timelock proposal"); + return res.json(); +} + +export interface DividendDistributionResult { + totalDistributed: number; + perKeyAmount: number; + holderCount: number; +} + +function normalizeDividendResult( + raw: any, + fallbackAmount: number +): DividendDistributionResult { + return { + totalDistributed: + raw.totalDistributed ?? raw.total_distributed ?? fallbackAmount, + perKeyAmount: raw.perKeyAmount ?? raw.per_key_amount ?? 0, + holderCount: raw.holderCount ?? raw.holder_count ?? 0, + }; +} + +export async function distributeDividend( + keyId: string, + amount: number, + walletAddress: string, + token?: string +): Promise { + const res = await fetch(`${API_BASE}/keys/${keyId}/distribute-dividend`, { export type WalletActivityType = | "buy" @@ -967,6 +1164,10 @@ export async function approveKeyPause( "Content-Type": "application/json", ...authHeaders(token), }, + body: JSON.stringify({ amount, wallet: walletAddress }), + }); + if (!res.ok) throw new Error("Dividend distribution failed"); + return normalizeDividendResult(await res.json(), amount); }); if (!res.ok) { throw new Error(await readErrorMessage(res, "Failed to approve pause")); diff --git a/vitest.setup.ts b/vitest.setup.ts index 9a41425..91c968f 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,11 @@ -import "@testing-library/jest-dom/vitest"; \ No newline at end of file +import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement ResizeObserver, which recharts' ResponsiveContainer +// subscribes to on mount. +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +}