From 471183431a1e5d77f342fe90df35f8e02ebdabe3 Mon Sep 17 00:00:00 2001 From: FrankHood1 Date: Tue, 27 Oct 2026 00:55:01 +0100 Subject: [PATCH 1/3] feat: Market maker indicator, treasury dashboard, proposal countdown, fee history chart - #1237: Add MarketMakerChip with tooltip to key detail stat row; MM badge on secondary market section; useKeyMarketMaker hook - #1239: Add TreasuryManagementDashboard with per-source donut chart, recent inflows table, Allocate Funds form, and paginated allocation history - #1243: Add ProposalExecutionCountdown with timelock countdown, Execute button, and Notify Me localStorage toggle with reminder toast - #1244: Add KeyFeeHistoryChart step chart with time range selector, bracket threshold markers, and empty state --- .../admin/TreasuryManagementDashboard.tsx | 446 ++++++++++++++++++ src/components/common/KeyFeeHistoryChart.tsx | 211 +++++++++ src/components/common/MarketMakerChip.tsx | 65 +++ .../common/ProposalExecutionCountdown.tsx | 183 +++++++ src/hooks/useKeyFeeHistory.ts | 49 ++ src/hooks/useKeyMarketMaker.ts | 42 ++ src/pages/AdminDashboardPage.tsx | 4 + src/pages/CreatorDashboardPage.tsx | 5 + src/pages/CreatorDetailPage.tsx | 17 +- src/pages/ProposalDetailPage.tsx | 10 + src/services/admin.service.ts | 71 +++ src/types/governance.ts | 2 + 12 files changed, 1104 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/TreasuryManagementDashboard.tsx create mode 100644 src/components/common/KeyFeeHistoryChart.tsx create mode 100644 src/components/common/MarketMakerChip.tsx create mode 100644 src/components/common/ProposalExecutionCountdown.tsx create mode 100644 src/hooks/useKeyFeeHistory.ts create mode 100644 src/hooks/useKeyMarketMaker.ts diff --git a/src/components/admin/TreasuryManagementDashboard.tsx b/src/components/admin/TreasuryManagementDashboard.tsx new file mode 100644 index 00000000..230882de --- /dev/null +++ b/src/components/admin/TreasuryManagementDashboard.tsx @@ -0,0 +1,446 @@ +import { useState } from 'react'; +import { + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip as RechartsTooltip, +} from 'recharts'; +import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Address } from '@stellar/stellar-sdk'; +import { CircleDollarSign, Loader2, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { adminService, type AllocateFundsInput } from '@/services/admin.service'; +import { formatStroops, parseXlmToStroops } from '@/utils/treasury.utils'; +import showToast from '@/utils/toast.util'; + +const PANEL_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +const DONUT_COLORS = [ + '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', +]; + +function timestamp(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function shortenAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function validAddress(address: string): boolean { + try { + Address.fromString(address.trim()); + return true; + } catch { + return false; + } +} + +function LoadingRows({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+ ))} + Loading… +
+ ); +} + +function DataError({ onRetry }: { onRetry: () => void }) { + return ( +
+

Failed to load data.

+ +
+ ); +} + +interface AllocateFormProps { + adminAddress: string; + onSuccess?: () => void; +} + +function AllocateForm({ adminAddress, onSuccess }: AllocateFormProps) { + const queryClient = useQueryClient(); + const [destination, setDestination] = useState(''); + const [amount, setAmount] = useState(''); + const [purpose, setPurpose] = useState(''); + + const mutation = useMutation({ + mutationFn: (input: AllocateFundsInput) => adminService.allocateFunds(input), + onSuccess: () => { + showToast.success('Funds allocated successfully.'); + setDestination(''); + setAmount(''); + setPurpose(''); + void queryClient.invalidateQueries({ queryKey: ['treasury', 'allocations'] }); + void queryClient.invalidateQueries({ queryKey: ['treasury', 'balance'] }); + onSuccess?.(); + }, + onError: (err: Error) => { + showToast.error(err.message || 'Allocation failed.'); + }, + }); + + const amountStroops = parseXlmToStroops(amount); + const addressValid = destination === '' || validAddress(destination); + const canSubmit = + validAddress(destination) && + amountStroops !== null && + amountStroops > 0n && + purpose.trim().length > 0 && + !mutation.isPending; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || amountStroops === null) return; + mutation.mutate({ + admin: adminAddress, + destinationAddress: destination.trim(), + amountStroops: amountStroops.toString(), + purpose: purpose.trim(), + }); + }; + + return ( +
+

+ Allocate Funds +

+

+ Send treasury funds to an approved destination with a recorded purpose. +

+ +
+
+ + setDestination(e.target.value)} + aria-invalid={!addressValid} + className="h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 font-mono text-sm text-white outline-none focus:border-amber-400/50" + placeholder="G… or C…" + /> + {!addressValid && ( +

+ Enter a valid Stellar address. +

+ )} +
+ +
+
+ + setAmount(e.target.value)} + aria-invalid={amount !== '' && amountStroops === null} + className="h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 font-mono text-sm text-white outline-none focus:border-amber-400/50" + placeholder="0.0000000" + /> +
+ +
+ + setPurpose(e.target.value)} + className="h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-amber-400/50" + placeholder="e.g. Liquidity incentive Q4" + /> +
+
+ + {mutation.isError && ( +

+ {mutation.error?.message || 'Allocation failed. Please try again.'} +

+ )} + + +
+
+ ); +} + +export default function TreasuryManagementDashboard({ adminAddress }: { adminAddress: string }) { + // Balance + const balance = useQuery({ + queryKey: ['treasury', 'balance'], + queryFn: () => adminService.getTreasuryBalance(), + staleTime: 15_000, + }); + + // Fee breakdown for donut chart + const breakdown = useQuery({ + queryKey: ['treasury', 'fee-breakdown'], + queryFn: () => adminService.getTreasuryFeeBreakdown(), + staleTime: 30_000, + }); + + // Recent inflows + const inflows = useQuery({ + queryKey: ['treasury', 'fee-events'], + queryFn: () => adminService.getTreasuryFeeEvents(), + staleTime: 15_000, + }); + + // Allocation history (paginated) + const allocations = useInfiniteQuery({ + queryKey: ['treasury', 'allocations'], + queryFn: ({ pageParam }) => + adminService.getTreasuryAllocations(pageParam as string | undefined), + initialPageParam: undefined as string | undefined, + getNextPageParam: page => page.nextCursor ?? undefined, + }); + + const allAllocations = allocations.data?.pages.flatMap(p => p.items) ?? []; + const hasNextPage = allocations.hasNextPage; + + return ( +
+ {/* Balance + Donut Chart */} +
+
+
+

+ Balance Overview +

+ +
+ {/* Total balance */} +
+

Total Balance

+ {balance.isLoading ? ( +
+ ) : balance.isError ? ( + void balance.refetch()} /> + ) : ( + <> +

+ {formatStroops(balance.data?.accumulatedFeesStroops ?? '0')} +

+ {balance.data?.updatedAt && ( +

+ Updated {timestamp(balance.data.updatedAt)} +

+ )} + + )} +
+ + {/* Per-source donut */} +
+

+ Per-Source Breakdown +

+ {breakdown.isLoading ? ( +
+ ) : breakdown.isError || !breakdown.data?.length ? ( +

No breakdown data available.

+ ) : ( +
+ + + + {breakdown.data.map((entry, index) => ( + + ))} + + [ + `${value.toFixed(1)}%`, + name, + ]} + contentStyle={{ + background: '#0a1929', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: 8, + fontSize: 12, + }} + /> + + +
    + {breakdown.data.map((entry, index) => ( +
  • +
  • + ))} +
+
+ )} +
+
+
+ + {/* Recent Inflows */} +
+

+ Recent Inflows +

+

Last 20 fee receipts collected into the treasury.

+ {inflows.isLoading ? ( +
+ ) : inflows.isError ? ( +
void inflows.refetch()} />
+ ) : !inflows.data?.length ? ( +

+ No fee inflows yet. +

+ ) : ( +
+ + + + + + + + + + + {inflows.data.slice(0, 20).map(item => ( + + + + + + + ))} + +
DateSourceTraderAmount
+ {timestamp(item.collectedAt)} + + {shortenAddress(item.creatorAddress)} + + {shortenAddress(item.traderAddress)} + + {formatStroops(item.amountStroops)} +
+
+ )} +
+ + {/* Allocate Funds Form */} + + + {/* Allocation History */} +
+

+ Allocation History +

+

All recorded fund allocations with purpose and destination.

+ + {allocations.isLoading ? ( +
+ ) : allocations.isError ? ( +
void allocations.refetch()} />
+ ) : !allAllocations.length ? ( +

+ No allocations recorded yet. +

+ ) : ( + <> +
+ + + + + + + + + + + {allAllocations.map(item => ( + + + + + + + ))} + +
DateDestinationPurposeAmount
+ {timestamp(item.allocatedAt)} + + {shortenAddress(item.destinationAddress)} + + {item.purpose} + + {formatStroops(item.amountStroops)} +
+
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/common/KeyFeeHistoryChart.tsx b/src/components/common/KeyFeeHistoryChart.tsx new file mode 100644 index 00000000..e6c5470b --- /dev/null +++ b/src/components/common/KeyFeeHistoryChart.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { + CartesianGrid, + ReferenceLine, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, + LineChart, + Line, +} from 'recharts'; +import Skeleton from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { useKeyFeeHistory, type FeeHistoryRange } from '@/hooks/useKeyFeeHistory'; + +interface KeyFeeHistoryChartProps { + keyId: string; + className?: string; +} + +const RANGE_OPTIONS: Array<{ value: FeeHistoryRange; label: string }> = [ + { value: '7d', label: '7D' }, + { value: '30d', label: '30D' }, + { value: 'all', label: 'All' }, +]; + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function formatBps(bps: number): string { + return `${(bps / 100).toFixed(2)}%`; +} + +interface TooltipPayload { + payload?: { timestamp: string; feeBps: number; bracketLabel?: string }; +} + +function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { + if (!active || !payload?.length) return null; + const point = payload[0]?.payload; + if (!point) return null; + return ( +
+

{formatTimestamp(point.timestamp)}

+

+ Fee: {formatBps(point.feeBps)} +

+ {point.bracketLabel && ( +

{point.bracketLabel}

+ )} +
+ ); +} + +export default function KeyFeeHistoryChart({ keyId, className }: KeyFeeHistoryChartProps) { + const [range, setRange] = useState('30d'); + const { data, isLoading, isError, refetch } = useKeyFeeHistory(keyId, range); + + const hasData = (data?.points?.length ?? 0) > 0; + + return ( +
+
+
+

+ Dynamic Fees +

+

+ Fee History +

+
+ +
+ {data && ( + + {formatBps(data.currentFeeBps)} + {data.currentBracketLabel && ( + + · {data.currentBracketLabel} + + )} + + )} + +
+ {RANGE_OPTIONS.map(opt => ( + + ))} +
+
+
+ +
+ {isLoading && ( +
+ +
+ )} + + {isError && ( +
+

Failed to load fee history.

+ +
+ )} + + {!isLoading && !isError && !hasData && ( +
+

No fee history for this period.

+
+ )} + + {!isLoading && !isError && hasData && ( + + + + + + } + cursor={{ stroke: 'rgba(255,255,255,0.12)', strokeWidth: 1 }} + /> + {/* Bracket threshold markers */} + {data!.bracketThresholds?.map(threshold => ( + + ))} + + + + )} +
+
+ ); +} diff --git a/src/components/common/MarketMakerChip.tsx b/src/components/common/MarketMakerChip.tsx new file mode 100644 index 00000000..e1ccfd7b --- /dev/null +++ b/src/components/common/MarketMakerChip.tsx @@ -0,0 +1,65 @@ +import { Tooltip } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { MarketMakerInfo } from '@/hooks/useKeyMarketMaker'; + +interface MarketMakerChipProps { + maker: MarketMakerInfo; + className?: string; +} + +function shortenWallet(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export function MarketMakerChip({ maker, className }: MarketMakerChipProps) { + const tooltipContent = ( +
+

Market Maker Active

+

+ Spread:{' '} + + {(maker.spreadBps / 100).toFixed(2)}% + +

+

+ Wallet:{' '} + + {shortenWallet(maker.walletAddress)} + +

+
+ ); + + return ( + + + + + ); +} + +export function MMBadge({ className }: { className?: string }) { + return ( + + MM + + ); +} diff --git a/src/components/common/ProposalExecutionCountdown.tsx b/src/components/common/ProposalExecutionCountdown.tsx new file mode 100644 index 00000000..0808ce37 --- /dev/null +++ b/src/components/common/ProposalExecutionCountdown.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from 'react'; +import { Bell, BellOff, CheckCircle2, Clock3, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import showToast from '@/utils/toast.util'; + +export interface ProposalExecutionCountdownProps { + proposalId: string; + timelockExpiresAt: string; + onExecute?: () => Promise; + isExecuting?: boolean; +} + +function parseMs(isoOrMs: string): number { + const asNumber = Number(isoOrMs); + if (!Number.isNaN(asNumber)) return asNumber; + const date = new Date(isoOrMs); + return Number.isNaN(date.getTime()) ? 0 : date.getTime(); +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; + total: number; +} + +function calcTimeLeft(expiresAtMs: number): TimeLeft { + const total = Math.max(0, expiresAtMs - Date.now()); + const seconds = Math.floor((total / 1000) % 60); + const minutes = Math.floor((total / 1000 / 60) % 60); + const hours = Math.floor((total / 1000 / 60 / 60) % 24); + const days = Math.floor(total / 1000 / 60 / 60 / 24); + return { days, hours, minutes, seconds, total }; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +const NOTIFY_KEY_PREFIX = 'proposal_notify_'; + +export default function ProposalExecutionCountdown({ + proposalId, + timelockExpiresAt, + onExecute, + isExecuting = false, +}: ProposalExecutionCountdownProps) { + const expiresAtMs = parseMs(timelockExpiresAt); + const [timeLeft, setTimeLeft] = useState(() => calcTimeLeft(expiresAtMs)); + + const notifyKey = `${NOTIFY_KEY_PREFIX}${proposalId}`; + const [notifyEnabled, setNotifyEnabled] = useState(() => { + try { + return localStorage.getItem(notifyKey) === 'true'; + } catch { + return false; + } + }); + + const toastFiredRef = useRef(false); + + useEffect(() => { + const tick = () => { + const tl = calcTimeLeft(expiresAtMs); + setTimeLeft(tl); + + if (notifyEnabled && !toastFiredRef.current && tl.total > 0 && tl.total <= 3_600_000) { + toastFiredRef.current = true; + const mins = Math.ceil(tl.total / 60_000); + showToast.message(`⏰ Proposal timelock expires in ${mins} minute${mins === 1 ? '' : 's'}.`); + } + }; + + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [expiresAtMs, notifyEnabled]); + + const toggleNotify = () => { + const next = !notifyEnabled; + setNotifyEnabled(next); + try { + if (next) { + localStorage.setItem(notifyKey, 'true'); + } else { + localStorage.removeItem(notifyKey); + } + } catch { + // localStorage may be blocked in private browsing + } + }; + + const expired = timeLeft.total === 0; + + return ( +
+
+
+
+ + +
+ +

+ {expired + ? 'The timelock has expired. This proposal is ready to execute.' + : 'Time remaining before the parameter change can be executed on-chain.'} +

+ + {!expired && ( +
+ {[ + { value: timeLeft.days, label: 'Days' }, + { value: timeLeft.hours, label: 'Hours' }, + { value: timeLeft.minutes, label: 'Min' }, + { value: timeLeft.seconds, label: 'Sec' }, + ].map(({ value, label }) => ( +
+

+ {pad(value)} +

+

+ {label} +

+
+ ))} +
+ )} + + {expired && ( +
+
+ )} + + {onExecute && ( + + )} +
+ ); +} diff --git a/src/hooks/useKeyFeeHistory.ts b/src/hooks/useKeyFeeHistory.ts new file mode 100644 index 00000000..bb9d6337 --- /dev/null +++ b/src/hooks/useKeyFeeHistory.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { BaseApiService, type APIResponse } from '@/services/api.service'; +import { cacheManager } from '@/utils/cache.utils'; + +export type FeeHistoryRange = '7d' | '30d' | 'all'; + +export interface FeeHistoryPoint { + timestamp: string; + feeBps: number; + bracketLabel?: string; +} + +export interface FeeHistoryResponse { + points: FeeHistoryPoint[]; + currentFeeBps: number; + currentBracketLabel?: string; + bracketThresholds?: Array<{ feeBps: number; label: string; volumeXlm: number }>; +} + +class KeyFeeHistoryService extends BaseApiService { + async getFeeHistory(keyId: string, range: FeeHistoryRange): Promise { + const cacheKey = `fee-history_${keyId}_${range}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + `/keys/${keyId}/fee/history`, + { params: { range } } + ); + const data = response.data.data; + cacheManager.set(cacheKey, data, 60_000); + return data; + } catch (error) { + throw this.handleError(error); + } + } +} + +const keyFeeHistoryService = new KeyFeeHistoryService(); + +export function useKeyFeeHistory(keyId: string, range: FeeHistoryRange = '30d') { + return useQuery({ + queryKey: ['keys', keyId, 'fee-history', range], + queryFn: () => keyFeeHistoryService.getFeeHistory(keyId, range), + enabled: Boolean(keyId), + staleTime: 60_000, + }); +} diff --git a/src/hooks/useKeyMarketMaker.ts b/src/hooks/useKeyMarketMaker.ts new file mode 100644 index 00000000..393d8215 --- /dev/null +++ b/src/hooks/useKeyMarketMaker.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { BaseApiService, type APIResponse } from '@/services/api.service'; +import { cacheManager } from '@/utils/cache.utils'; + +const CACHE_TTL = 30_000; + +export interface MarketMakerInfo { + walletAddress: string; + spreadBps: number; + active: boolean; +} + +class KeyMarketMakerService extends BaseApiService { + async getMarketMaker(keyId: string): Promise { + const cacheKey = `market-maker_${keyId}`; + const cached = cacheManager.get(cacheKey); + if (cached !== undefined) return cached; + + try { + const response = await this.api.get>( + `/keys/${keyId}/market-maker` + ); + const data = response.data.data ?? null; + cacheManager.set(cacheKey, data, CACHE_TTL); + return data; + } catch (error) { + throw this.handleError(error); + } + } +} + +const keyMarketMakerService = new KeyMarketMakerService(); + +export function useKeyMarketMaker(keyId: string) { + return useQuery({ + queryKey: ['keys', keyId, 'market-maker'], + queryFn: () => keyMarketMakerService.getMarketMaker(keyId), + enabled: Boolean(keyId), + staleTime: 20_000, + refetchInterval: 30_000, + }); +} diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx index f16a9cfc..8b104d9e 100644 --- a/src/pages/AdminDashboardPage.tsx +++ b/src/pages/AdminDashboardPage.tsx @@ -4,6 +4,7 @@ import OracleAccessPanel from '@/components/admin/OracleAccessPanel'; import AclWhitelistPanel from '@/components/admin/AclWhitelistPanel'; import UpgradeProxyPanel from '@/components/admin/UpgradeProxyPanel'; import TreasuryPanel from '@/components/admin/TreasuryPanel'; +import TreasuryManagementDashboard from '@/components/admin/TreasuryManagementDashboard'; import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useStellarWallet } from '@/hooks/useStellarWallet'; import { isAdminWallet } from '@/utils/adminAccess'; @@ -57,6 +58,9 @@ export default function AdminDashboardPage() { + + + diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index 4525be76..46763622 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -48,6 +48,7 @@ import CreatorRevenuePanel from '@/components/common/CreatorRevenuePanel'; import DistributeRevenuePanel from '@/components/common/DistributeRevenuePanel'; import CreatorAnnouncementsPanel from '@/components/common/CreatorAnnouncementsPanel'; import VestingSchedulesViewer from '@/components/creator/VestingSchedulesViewer'; +import KeyFeeHistoryChart from '@/components/common/KeyFeeHistoryChart'; const TABS = [ { label: 'Overview', value: 'overview' }, @@ -311,6 +312,10 @@ export default function CreatorDashboardPage() {
)} + {activeTab === 'overview' && ( + + )} + {activeTab === 'overview' && ( + {proposal.status === 'passed' && proposal.timelockExpiresAt && ( +
+ +
+ )} +

Result and participation diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts index 8c0d2c01..f9217aec 100644 --- a/src/services/admin.service.ts +++ b/src/services/admin.service.ts @@ -50,6 +50,37 @@ export interface TreasuryDistributionSubmission { transactionHash: string; } +/** Per-source fee breakdown used for the treasury donut chart. */ +export interface TreasuryFeeSourceBreakdown { + source: string; + amountStroops: string; + /** Percentage share of total, 0–100. */ + percentage: number; +} + +/** A single allocation record (single-destination fund allocation). */ +export interface TreasuryAllocation { + id: string; + destinationAddress: string; + amountStroops: string; + purpose: string; + allocatedAt: string; + transactionHash: string; + admin: string; +} + +export interface TreasuryAllocationsPage { + items: TreasuryAllocation[]; + nextCursor: string | null; +} + +export interface AllocateFundsInput { + admin: string; + destinationAddress: string; + amountStroops: string; + purpose: string; +} + export interface AclContract { address: string; functions: string[]; @@ -619,6 +650,46 @@ class AdminService extends BaseApiService { } } + /** Read per-source fee breakdown for the treasury donut chart. */ + async getTreasuryFeeBreakdown(): Promise { + try { + const response = await this.api.get>( + '/admin/treasury/fee-breakdown' + ); + return response.data.data ?? []; + } catch (error) { + throw this.handleError(error); + } + } + + /** Read paginated allocation history. */ + async getTreasuryAllocations(cursor?: string): Promise { + try { + const params: Record = {}; + if (cursor) params.cursor = cursor; + const response = await this.api.get>( + '/admin/treasury/allocations', + { params } + ); + return response.data.data ?? { items: [], nextCursor: null }; + } catch (error) { + throw this.handleError(error); + } + } + + /** Allocate treasury funds to a single destination. */ + async allocateFunds(input: AllocateFundsInput): Promise<{ transactionHash: string }> { + try { + const response = await this.api.post>( + '/admin/treasury/allocate', + input + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + /** * Request a treasury distribution. The admin API must authorize the wallet, * submit the contract transaction, and return its confirmed transaction hash. diff --git a/src/types/governance.ts b/src/types/governance.ts index 3d53643a..cbe8cb42 100644 --- a/src/types/governance.ts +++ b/src/types/governance.ts @@ -87,6 +87,8 @@ export interface Proposal { oldValue?: string | number; /** Proposed or executed new protocol parameter value (#1126). */ newValue?: string | number; + /** ISO timestamp (or ms epoch) when the timelock expires and the proposal can be executed (#1243). */ + timelockExpiresAt?: string; } /** Props accepted by the QuorumIndicator component. */ From acebbb8f60d14a5cc3b8e6fe5430a424ca281a83 Mon Sep 17 00:00:00 2001 From: FrankHood1 Date: Tue, 27 Oct 2026 12:42:14 +0100 Subject: [PATCH 2/3] fix: remove duplicate unclosed h2 wrapper in secondary market panel Stray outer div+h2 from a merge artifact was left before the real secondary-market-sell-panel div, causing an unclosed JSX h2 element that failed the ESLint parse check in CI (line 1388). --- src/pages/CreatorDetailPage.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 943a1c0e..0bd44ef1 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -1383,9 +1383,6 @@ function CreatorDetailPageContent() { - {/* Secondary market sell panel with auction tab (#1174) */} -
-

Date: Tue, 27 Oct 2026 12:45:19 +0100 Subject: [PATCH 3/3] fix: drop explicit types on Recharts formatter to satisfy TS2322 Recharts Formatter is a union type; annotating the callback parameters as (number, string) narrows them to a subtype that TypeScript rejects. Removing the annotations lets TS infer the correct types from the overloaded Formatter signature. --- src/components/admin/TreasuryManagementDashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/admin/TreasuryManagementDashboard.tsx b/src/components/admin/TreasuryManagementDashboard.tsx index 230882de..a3d51e01 100644 --- a/src/components/admin/TreasuryManagementDashboard.tsx +++ b/src/components/admin/TreasuryManagementDashboard.tsx @@ -287,8 +287,8 @@ export default function TreasuryManagementDashboard({ adminAddress }: { adminAdd ))} [ - `${value.toFixed(1)}%`, + formatter={(value, name) => [ + `${Number(value).toFixed(1)}%`, name, ]} contentStyle={{