From be5c3a48a77251f21a667311b680ec2e459810f5 Mon Sep 17 00:00:00 2001 From: davidugorji Date: Thu, 27 Aug 2026 12:02:56 +0100 Subject: [PATCH] feat: add creator revenue, supply cap, dividend, and timelock panels Adds four dashboard panels backed by shared API + react-query hooks that follow the existing lib/api normalize-and-fetch conventions. Supply cap configuration (#106) - SupplyCapSettings renders in a new creator-only Settings tab and shows the current cap, circulating supply, and remaining mintable. - The number input is pre-filled with the current cap; Save stays disabled unless the value is a positive integer >= circulatingSupply and changed. - A 409 from the server is surfaced as an inline error rather than a toast, so the creator can correct the value in place. updateKeySupplyCap tags conflicts via isApiConflictError; a plain tagged Error is used instead of a class so the module keeps only type-level exports and existing vi.spyOn(api, ...) call sites are unaffected. - Renders "No cap set" for both the placeholder and the stats when the cap is null. The pre-existing read-only SupplyCapSection in BuyKeyPanel is left untouched; it serves buyers, not creators. Creator revenue (#105) - CreatorRevenueSection shows total/buy/sell royalty stat cards and a recharts bar chart of the trailing 12 months, with a loading skeleton and a "No revenue yet" empty state when tradeCount is zero. - Months are sliced to the most recent 12 so an over-long API response cannot stretch the axis. - Chart values are mirrored into an sr-only list. Recharts renders values only inside the SVG and on hover, so this is what makes the amounts reachable by screen readers and assertable in tests. Dividend distribution (#109) - DistributeDividendsPanel previews perKeyAmount as amount / circulatingSupply, shows holder count and circulating keys, and reports totalDistributed plus perKeyAmount in the success toast. - Submit is disabled when the amount is non-positive, when circulating supply is zero (division would be undefined), or while signing. Timelock proposals (#104) - TimelockProposalsPanel adds a Timelock tab to the admin dashboard listing pending proposals with change type, payload summary, proposedAt, and executionNotBefore, split from a separate Completed section. - A 1s ticker drives the countdown to the execution window and gates the Execute button, so a proposal becomes executable without a reload. The interval only runs while pending proposals exist. useCountdown was not reused because it ticks per minute and omits seconds. - Cancel requires confirmation before firing, then the list refetches. Testing - 22 tests across the four panels covering every acceptance criterion. - vitest.setup.ts stubs ResizeObserver, which jsdom lacks and recharts' ResponsiveContainer subscribes to on mount. This is the repo's first recharts usage, so no chart could render under test without it. Closes #104 Closes #105 Closes #106 Closes #109 --- app/admin/page.tsx | 23 +- components/admin/TimelockProposalsPanel.tsx | 300 ++++++++++++++++++ .../__tests__/TimelockProposalsPanel.test.tsx | 200 ++++++++++++ components/keys/CreatorKeyDetail.tsx | 36 ++- components/keys/CreatorRevenueSection.tsx | 169 ++++++++++ components/keys/DistributeDividendsPanel.tsx | 134 ++++++++ components/keys/SupplyCapSettings.tsx | 175 ++++++++++ .../__tests__/CreatorRevenueSection.test.tsx | 142 +++++++++ .../DistributeDividendsPanel.test.tsx | 176 ++++++++++ .../keys/__tests__/SupplyCapSettings.test.tsx | 191 +++++++++++ hooks/useCreatorKeys.ts | 46 +++ hooks/useCreatorRevenue.ts | 14 + hooks/useTimelockProposals.ts | 60 ++++ lib/api/index.ts | 207 ++++++++++++ vitest.setup.ts | 12 +- 15 files changed, 1876 insertions(+), 9 deletions(-) create mode 100644 components/admin/TimelockProposalsPanel.tsx create mode 100644 components/admin/__tests__/TimelockProposalsPanel.test.tsx create mode 100644 components/keys/CreatorRevenueSection.tsx create mode 100644 components/keys/DistributeDividendsPanel.tsx create mode 100644 components/keys/SupplyCapSettings.tsx create mode 100644 components/keys/__tests__/CreatorRevenueSection.test.tsx create mode 100644 components/keys/__tests__/DistributeDividendsPanel.test.tsx create mode 100644 components/keys/__tests__/SupplyCapSettings.test.tsx create mode 100644 hooks/useCreatorRevenue.ts create mode 100644 hooks/useTimelockProposals.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index e926c29..e202edb 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,11 +3,12 @@ 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">( - "invoices" - ); + const [activeTab, setActiveTab] = useState< + "invoices" | "audit-log" | "timelock" + >("invoices"); return (
@@ -35,9 +36,23 @@ export default function AdminPage() { > Audit Log +
- {activeTab === "invoices" ? : } + {activeTab === "invoices" && } + {activeTab === "audit-log" && } + {activeTab === "timelock" && } ); } diff --git a/components/admin/TimelockProposalsPanel.tsx b/components/admin/TimelockProposalsPanel.tsx new file mode 100644 index 0000000..567fb70 --- /dev/null +++ b/components/admin/TimelockProposalsPanel.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/useAuth"; +import { + useCancelTimelockProposalMutation, + useExecuteTimelockProposalMutation, + useTimelockProposals, +} from "@/hooks/useTimelockProposals"; +import type { TimelockProposal } from "@/lib/api"; + +function formatDateTime(value: string): string { + if (!value) return "—"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} + +function summarizePayload(payload: Record): string { + const entries = Object.entries(payload ?? {}); + if (entries.length === 0) return "No payload"; + return entries + .map(([key, value]) => `${key}: ${formatPayloadValue(value)}`) + .join(", "); +} + +function formatPayloadValue(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function formatRemaining(msRemaining: number): string { + const totalSeconds = Math.max(Math.floor(msRemaining / 1000), 0); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function useNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!active) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [active]); + + return now; +} + +interface PendingProposalRowProps { + proposal: TimelockProposal; + now: number; + onExecute: (proposal: TimelockProposal) => void; + onCancel: (proposal: TimelockProposal) => void; + isExecuting: boolean; + isCancelling: boolean; +} + +function PendingProposalRow({ + proposal, + now, + onExecute, + onCancel, + isExecuting, + isCancelling, +}: PendingProposalRowProps) { + const executionTime = new Date(proposal.executionNotBefore).getTime(); + const isExecutable = + !Number.isNaN(executionTime) && now >= executionTime; + const msRemaining = executionTime - now; + + return ( +
+
+
+

+ {proposal.changeType} +

+

+ {summarizePayload(proposal.payload)} +

+
+ {isExecutable ? ( + Ready + ) : ( + + Executable in {formatRemaining(msRemaining)} + + )} +
+ +
+ + Proposed {formatDateTime(proposal.proposedAt)} + + + Executable from {formatDateTime(proposal.executionNotBefore)} + +
+ +
+ + +
+
+ ); +} + +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 078e1c9..72a412a 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 { useCreatorKey, useKeySupply } from "@/hooks/useCreatorKeys"; import { useAuth } from "@/hooks/useAuth"; import { usePageTitle } from "@/hooks/usePageTitle"; @@ -35,9 +38,9 @@ function CreatorKeyDetailSkeleton() { } export function CreatorKeyDetail({ keyId }: CreatorKeyDetailProps) { - const [activeTab, setActiveTab] = useState<"overview" | "governance">( - "overview" - ); + const [activeTab, setActiveTab] = useState< + "overview" | "governance" | "settings" + >("overview"); const { jwt } = useAuth(); const { data: creatorKey, isLoading } = useCreatorKey(keyId, jwt); const { data: supply } = useKeySupply(keyId, jwt); @@ -104,6 +107,20 @@ export function CreatorKeyDetail({ keyId }: CreatorKeyDetailProps) { > Governance + {creatorKey.is_creator && ( + + )} {activeTab === "overview" ? ( @@ -129,7 +146,18 @@ export function CreatorKeyDetail({ keyId }: CreatorKeyDetailProps) { ) : null} {activeTab === "overview" && creatorKey.is_creator && ( - + <> + + + + + )} + + {activeTab === "settings" && creatorKey.is_creator && ( + )} {activeTab === "governance" && ( diff --git a/components/keys/CreatorRevenueSection.tsx b/components/keys/CreatorRevenueSection.tsx new file mode 100644 index 0000000..0d5df6a --- /dev/null +++ b/components/keys/CreatorRevenueSection.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/useAuth"; +import { useCreatorRevenue } from "@/hooks/useCreatorRevenue"; +import type { MonthlyRevenue } from "@/lib/api"; + +interface CreatorRevenueSectionProps { + keyId: string; +} + +const MONTHS_SHOWN = 12; + +function formatXlm(value: number): string { + return `${value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + })} XLM`; +} + +function formatMonthLabel(month: string): string { + const parsed = new Date(`${month}-01T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) { + return month; + } + return parsed.toLocaleDateString(undefined, { + month: "short", + timeZone: "UTC", + }); +} + +function lastTwelveMonths(breakdown: MonthlyRevenue[]): MonthlyRevenue[] { + return breakdown.slice(-MONTHS_SHOWN); +} + +function RevenueStatCard({ + label, + value, + testId, +}: { + label: string; + value: number; + testId: string; +}) { + return ( +
+

{label}

+

+ {formatXlm(value)} +

+
+ ); +} + +export function CreatorRevenueSection({ keyId }: CreatorRevenueSectionProps) { + const { jwt } = useAuth(); + const revenueQuery = useCreatorRevenue(keyId, jwt); + + if (revenueQuery.isLoading) { + return ( + + +

Revenue

+
+ +
+ + + +
+ +
+
+ ); + } + + const revenue = revenueQuery.data; + + if (!revenue) { + return null; + } + + if (revenue.tradeCount === 0) { + return ( + + +

Revenue

+
+ +

+ No revenue yet +

+
+
+ ); + } + + const chartData = lastTwelveMonths(revenue.monthlyBreakdown).map((entry) => ({ + ...entry, + label: formatMonthLabel(entry.month), + })); + + return ( + + +

Revenue

+
+ +
+ + + +
+ +
+ + + + + + [formatXlm(value), "Royalties"]} + /> + + + +
+ +
    + {chartData.map((entry) => ( +
  • + {entry.label}: {formatXlm(entry.royaltyEarned)} +
  • + ))} +
+
+
+ ); +} diff --git a/components/keys/DistributeDividendsPanel.tsx b/components/keys/DistributeDividendsPanel.tsx new file mode 100644 index 0000000..4db0d8c --- /dev/null +++ b/components/keys/DistributeDividendsPanel.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/hooks/useAuth"; +import { + useDistributeDividendMutation, + useKeySupply, +} from "@/hooks/useCreatorKeys"; +import { useStellarWallet } from "@/hooks/useStellarWallet"; + +interface DistributeDividendsPanelProps { + keyId: string; + holdersCount: number; +} + +function formatXlm(value: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + }); +} + +export function DistributeDividendsPanel({ + keyId, + holdersCount, +}: DistributeDividendsPanelProps) { + const { address: authAddress, jwt } = useAuth(); + const { address: walletAddress } = useStellarWallet(); + const { data: supply } = useKeySupply(keyId, jwt); + const distributeMutation = useDistributeDividendMutation(keyId); + + const [amount, setAmount] = useState(""); + + const senderAddress = authAddress ?? walletAddress; + const circulatingSupply = supply?.circulatingSupply ?? 0; + const parsedAmount = Number(amount); + const hasValidAmount = + amount.trim() !== "" && Number.isFinite(parsedAmount) && parsedAmount > 0; + + const perKeyAmount = useMemo(() => { + if (!hasValidAmount || circulatingSupply <= 0) return 0; + return parsedAmount / circulatingSupply; + }, [circulatingSupply, hasValidAmount, parsedAmount]); + + const canSubmit = + hasValidAmount && + circulatingSupply > 0 && + Boolean(senderAddress) && + !distributeMutation.isPending; + + const handleSubmit = async () => { + if (!canSubmit || !senderAddress) return; + + const result = await distributeMutation.mutateAsync({ + amount: parsedAmount, + walletAddress: senderAddress, + token: jwt, + }); + + toast.success( + `Distributed ${formatXlm(result.totalDistributed)} XLM — ${formatXlm( + result.perKeyAmount + )} XLM per key` + ); + setAmount(""); + }; + + return ( + + +

Distribute Dividends

+

+ Send XLM to every holder, split evenly across all circulating keys. +

+
+ +
+ + setAmount(event.target.value)} + data-testid="dividend-amount-input" + /> +
+ +
+
+ Per key + + {formatXlm(perKeyAmount)} XLM + +
+

+ {holdersCount.toLocaleString()} holders ·{" "} + {circulatingSupply.toLocaleString()} keys circulating +

+
+ + {circulatingSupply <= 0 && ( +

+ No keys are circulating yet, so there is nothing to distribute. +

+ )} + + +
+
+ ); +} 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 19651e9..a147424 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"; @@ -199,3 +201,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 a1a2925..64c474d 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -660,3 +660,210 @@ 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`, { + method: "POST", + headers: { + "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); +} 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() {} + }; +}