From 1fd68587423b0a3f63ab0fae65731637b565d531 Mon Sep 17 00:00:00 2001 From: tek-lizzy Date: Wed, 30 Sep 2026 16:12:15 +0100 Subject: [PATCH 1/2] feat(admin): add treasury distribution history viewer --- docs/admin-treasury-api.md | 28 ++ src/components/admin/TreasuryPanel.tsx | 276 ++++++++++++++++++ .../admin/__tests__/TreasuryPanel.test.tsx | 95 ++++++ src/hooks/useTreasuryAdmin.ts | 58 ++++ src/lib/queryKeys.ts | 3 + src/pages/AdminDashboardPage.tsx | 2 + src/services/__tests__/admin.service.test.ts | 56 ++++ src/services/admin.service.ts | 107 +++++++ src/utils/__tests__/treasury.utils.test.ts | 51 ++++ src/utils/treasury.utils.ts | 64 ++++ 10 files changed, 740 insertions(+) create mode 100644 docs/admin-treasury-api.md create mode 100644 src/components/admin/TreasuryPanel.tsx create mode 100644 src/components/admin/__tests__/TreasuryPanel.test.tsx create mode 100644 src/hooks/useTreasuryAdmin.ts create mode 100644 src/utils/__tests__/treasury.utils.test.ts create mode 100644 src/utils/treasury.utils.ts diff --git a/docs/admin-treasury-api.md b/docs/admin-treasury-api.md new file mode 100644 index 00000000..9cafe204 --- /dev/null +++ b/docs/admin-treasury-api.md @@ -0,0 +1,28 @@ +# Admin treasury API contract + +The treasury panel calls the authenticated backend API; this client repository does not contain the Soroban contract source or its ABI/spec, so it deliberately does not invent contract entrypoint names or simulate successful transactions. The backend must read the deployed contract/event index and execute/confirm the real distribution transaction. + +All routes use the existing `BaseApiService` credentials and return the standard `{ success, data, message }` envelope. + +| Method | Route | `data` | +| --- | --- | --- | +| `GET` | `/admin/treasury` | `{ accumulatedFeesStroops: string, updatedAt?: string }` | +| `GET` | `/admin/treasury/distributions` | `TreasuryDistribution[]` | +| `GET` | `/admin/treasury/fees` | Recent `FeeCollected` records, newest first | +| `POST` | `/admin/treasury/distributions` | Confirmed `{ epoch: number, transactionHash: string }` | + +Distribution request body: + +```json +{ + "admin": "G...", + "totalAmountStroops": "10000000", + "recipients": [ + { "address": "G...", "amountStroops": "10000000" } + ] +} +``` + +Every amount is a non-negative decimal integer in stroops (1 XLM = 10,000,000 stroops). The client validates the Stellar addresses, rejects duplicate recipients, and requires the allocation sum to equal the displayed treasury balance. The backend must independently authenticate/authorize the admin, re-read the contract balance, validate allocations, submit the contract call, wait for successful confirmation, and only then return the epoch and transaction hash. A response without those confirmation fields is treated as a failure. + +Distribution records use `{ id, epoch, totalDistributedStroops, recipients: [{ address, amountStroops }], distributedAt, transactionHash }`. Fee event records use `{ id, creatorAddress, traderAddress, amountStroops, collectedAt, transactionHash }`. diff --git a/src/components/admin/TreasuryPanel.tsx b/src/components/admin/TreasuryPanel.tsx new file mode 100644 index 00000000..ddd7bb55 --- /dev/null +++ b/src/components/admin/TreasuryPanel.tsx @@ -0,0 +1,276 @@ +import { useMemo, useState } from 'react'; +import { Address } from '@stellar/stellar-sdk'; +import { CircleDollarSign, Loader2, Plus, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + useDistributeTreasuryFees, + useTreasuryBalance, + useTreasuryDistributions, + useTreasuryFeeEvents, +} from '@/hooks/useTreasuryAdmin'; +import type { TreasuryDistributionRecipient } from '@/services/admin.service'; +import { + formatStroops, + parseXlmToStroops, + validateTreasuryDistribution, +} from '@/utils/treasury.utils'; + +const PANEL_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +interface RecipientDraft { + id: number; + address: string; + amount: string; +} + +function timestamp(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function validAddress(address: string): boolean { + try { + Address.fromString(address.trim()); + return true; + } catch { + return false; + } +} + +function hasDistributableBalance(value: string | undefined): boolean { + return Boolean(value && /^\d+$/.test(value) && BigInt(value) > 0n); +} + +function LoadingRows({ count = 2 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }, (_, index) => ( +
+ ))} + Loading treasury data +
+ ); +} + +function DataError({ onRetry }: { onRetry: () => void }) { + return ( +
+

Treasury data could not be loaded.

+ +
+ ); +} + +export default function TreasuryPanel({ adminAddress }: { adminAddress: string }) { + const balance = useTreasuryBalance(); + const distributions = useTreasuryDistributions(); + const feeEvents = useTreasuryFeeEvents(); + const distribute = useDistributeTreasuryFees(); + const [dialogOpen, setDialogOpen] = useState(false); + const [recipients, setRecipients] = useState([]); + const [nextId, setNextId] = useState(1); + + const distributionRecipients = useMemo( + () => + recipients.map(recipient => ({ + address: recipient.address.trim(), + amountStroops: + parseXlmToStroops(recipient.amount)?.toString() ?? '', + })), + [recipients] + ); + const distributionError = balance.data + ? validateTreasuryDistribution( + balance.data.accumulatedFeesStroops, + distributionRecipients + ) + : 'Treasury balance is unavailable.'; + const dataLoading = balance.isLoading || distributions.isLoading || feeEvents.isLoading; + + const openDistributionDialog = () => { + setRecipients([{ id: nextId, address: '', amount: '' }]); + setNextId(value => value + 1); + setDialogOpen(true); + }; + + const addRecipient = () => { + setRecipients(current => [...current, { id: nextId, address: '', amount: '' }]); + setNextId(value => value + 1); + }; + + const updateRecipient = (id: number, field: 'address' | 'amount', value: string) => { + setRecipients(current => + current.map(recipient => + recipient.id === id ? { ...recipient, [field]: value } : recipient + ) + ); + }; + + const submitDistribution = (event: React.FormEvent) => { + event.preventDefault(); + if (!balance.data || distributionError) return; + distribute.mutate( + { + admin: adminAddress, + totalAmountStroops: balance.data.accumulatedFeesStroops, + recipients: distributionRecipients, + }, + { onSuccess: () => setDialogOpen(false) } + ); + }; + + return ( +
+
+
+
+
+
+

Accumulated fees

+

Unallocated protocol fees available for distribution.

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

+ {formatStroops(balance.data.accumulatedFeesStroops)} +

+ )} + {balance.data?.updatedAt &&

Updated {timestamp(balance.data.updatedAt)}

} +
+
+ +
+

Distribution history

+

Completed distribution epochs, recipients, and confirmed transaction dates.

+ {distributions.isLoading ?
: distributions.isError ? ( +
void distributions.refetch()} />
+ ) : (distributions.data?.length ?? 0) === 0 ? ( +

No treasury distributions yet.

+ ) : ( +
+ + + + + + {[...(distributions.data ?? [])].sort((a, b) => b.epoch - a.epoch).map(item => ( + + + + + + + ))} + +
EpochTotal distributedRecipientsDate
{item.epoch}{formatStroops(item.totalDistributedStroops)}{item.recipients.map(recipient => `${recipient.address} — ${formatStroops(recipient.amountStroops)}`).join('; ')}{timestamp(item.distributedAt)}
+
+ )} +
+ +
+

Recent fee contributions

+

Recent per-trade FeeCollected events added to the treasury.

+ {feeEvents.isLoading ?
: feeEvents.isError ? ( +
void feeEvents.refetch()} />
+ ) : (feeEvents.data?.length ?? 0) === 0 ? ( +

No fee collection events found.

+ ) : ( +
+ + + + + + {feeEvents.data?.slice(0, 20).map(item => ( + + + + + + + ))} + +
DateCreatorTraderFee collected
{timestamp(item.collectedAt)}{item.creatorAddress}{item.traderAddress}{formatStroops(item.amountStroops)}
+
+ )} +
+ + !distribute.isPending && setDialogOpen(open)}> + + + Distribute treasury fees + + Assign the full accumulated balance to one or more Stellar recipients. Amounts use XLM, up to 7 decimal places. + + +
+
+

Total to distribute

+

{balance.data ? formatStroops(balance.data.accumulatedFeesStroops) : '—'}

+
+
+ {recipients.map((recipient, index) => { + const addressInvalid = recipient.address !== '' && !validAddress(recipient.address); + const duplicate = recipient.address !== '' && recipients.some(other => other.id !== recipient.id && other.address.trim().toLowerCase() === recipient.address.trim().toLowerCase()); + return ( +
+
+ + updateRecipient(recipient.id, 'address', event.target.value)} aria-invalid={addressInvalid || duplicate} className="h-11 w-full rounded-lg border border-white/10 bg-black/20 px-3 font-mono text-xs text-white outline-none focus:border-amber-400/50" placeholder="G… or C…" /> + {(addressInvalid || duplicate) &&

{duplicate ? 'Recipient addresses must be unique.' : 'Enter a valid Stellar address.'}

} +
+
+ + updateRecipient(recipient.id, 'amount', event.target.value)} aria-invalid={recipient.amount !== '' && parseXlmToStroops(recipient.amount) === null} className="h-11 w-full rounded-lg 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" /> +
+ +
+ ); + })} + +
+ {distributionError &&

{distributionError}

} + {!distributionError &&

Recipient total matches the treasury balance.

} + + + + +
+
+
+
+ ); +} diff --git a/src/components/admin/__tests__/TreasuryPanel.test.tsx b/src/components/admin/__tests__/TreasuryPanel.test.tsx new file mode 100644 index 00000000..d4e08d60 --- /dev/null +++ b/src/components/admin/__tests__/TreasuryPanel.test.tsx @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Keypair } from '@stellar/stellar-sdk'; +import TreasuryPanel from '../TreasuryPanel'; + +const mocks = vi.hoisted(() => ({ + balance: vi.fn(), + distributions: vi.fn(), + fees: vi.fn(), + mutate: vi.fn(), +})); + +vi.mock('@/hooks/useTreasuryAdmin', () => ({ + useTreasuryBalance: mocks.balance, + useTreasuryDistributions: mocks.distributions, + useTreasuryFeeEvents: mocks.fees, + useDistributeTreasuryFees: () => ({ mutate: mocks.mutate, isPending: false }), +})); + +const ADMIN = Keypair.random().publicKey(); +const RECIPIENT = Keypair.random().publicKey(); + +function setSuccessfulQueries() { + mocks.balance.mockReturnValue({ + data: { accumulatedFeesStroops: '10000000', updatedAt: '2026-09-30T10:00:00Z' }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + mocks.distributions.mockReturnValue({ + data: [{ + id: 'dist-1', epoch: 2, totalDistributedStroops: '2500000', + recipients: [{ address: RECIPIENT, amountStroops: '2500000' }], + distributedAt: '2026-09-29T10:00:00Z', transactionHash: 'hash-1', + }], + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + mocks.fees.mockReturnValue({ + data: [{ + id: 'fee-1', creatorAddress: RECIPIENT, traderAddress: ADMIN, + amountStroops: '250000', collectedAt: '2026-09-30T09:00:00Z', transactionHash: 'fee-hash', + }], + isLoading: false, + isError: false, + refetch: vi.fn(), + }); +} + +describe('TreasuryPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + setSuccessfulQueries(); + }); + + it('renders the accumulated balance, epoch history, and recent fee events', () => { + render(); + + expect(screen.getByTestId('treasury-balance')).toHaveTextContent('1 XLM'); + expect(screen.getByTestId('treasury-distribution-row')).toHaveTextContent('2'); + expect(screen.getByTestId('treasury-distribution-row')).toHaveTextContent('0.25 XLM'); + expect(screen.getByTestId('treasury-fee-row')).toHaveTextContent('0.025 XLM'); + }); + + it('requires exact full-balance allocations and submits the admin distribution payload', () => { + render(); + fireEvent.click(screen.getByTestId('treasury-distribute-now')); + + fireEvent.change(screen.getByLabelText('Recipient 1 address'), { + target: { value: RECIPIENT }, + }); + fireEvent.change(screen.getByLabelText('Amount (XLM)'), { + target: { value: '0.9' }, + }); + const submit = screen.getByTestId('treasury-distribution-submit'); + expect(submit).toBeDisabled(); + expect(screen.getByTestId('treasury-distribution-validation')).toHaveTextContent('must equal'); + + fireEvent.change(screen.getByLabelText('Amount (XLM)'), { + target: { value: '1' }, + }); + expect(submit).toBeEnabled(); + fireEvent.click(submit); + + expect(mocks.mutate).toHaveBeenCalledWith( + { + admin: ADMIN, + totalAmountStroops: '10000000', + recipients: [{ address: RECIPIENT, amountStroops: '10000000' }], + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); +}); diff --git a/src/hooks/useTreasuryAdmin.ts b/src/hooks/useTreasuryAdmin.ts new file mode 100644 index 00000000..9ed510d5 --- /dev/null +++ b/src/hooks/useTreasuryAdmin.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + adminService, + type TreasuryDistributionInput, +} from '@/services/admin.service'; +import showToast from '@/utils/toast.util'; + +function messageFor(error: unknown): string { + return error instanceof Error + ? error.message + : 'Treasury request failed. Please try again.'; +} + +export function useTreasuryBalance() { + return useQuery({ + queryKey: queryKeys.admin.treasury(), + queryFn: () => adminService.getTreasuryBalance(), + refetchInterval: 30_000, + }); +} + +export function useTreasuryDistributions() { + return useQuery({ + queryKey: queryKeys.admin.treasuryDistributions(), + queryFn: () => adminService.getTreasuryDistributions(), + }); +} + +export function useTreasuryFeeEvents() { + return useQuery({ + queryKey: queryKeys.admin.treasuryFees(), + queryFn: () => adminService.getTreasuryFeeEvents(), + }); +} + +export function useDistributeTreasuryFees() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['admin', 'treasury', 'distribute'], + mutationFn: (input: TreasuryDistributionInput) => + adminService.distributeTreasuryFees(input), + onSuccess: result => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.admin.treasury(), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.admin.treasuryDistributions(), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.admin.treasuryFees(), + }); + showToast.success(`Treasury distribution epoch ${result.epoch} confirmed`); + }, + onError: error => showToast.error(messageFor(error)), + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index b12237ee..34fdbc66 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -102,6 +102,9 @@ export const queryKeys = { ratings: () => ['leaderboard', 'ratings'] as const, }, admin: { + treasury: () => ['admin', 'treasury', 'balance'] as const, + treasuryDistributions: () => ['admin', 'treasury', 'distributions'] as const, + treasuryFees: () => ['admin', 'treasury', 'fees'] as const, oracleCallers: () => ['admin', 'oracle', 'callers'] as const, multiSigPending: () => ['admin', 'multisig', 'pending'] as const, multiSigHistory: () => ['admin', 'multisig', 'history'] as const, diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx index 324a96d6..2b0dee24 100644 --- a/src/pages/AdminDashboardPage.tsx +++ b/src/pages/AdminDashboardPage.tsx @@ -3,6 +3,7 @@ import TimelockQueuePanel from '@/components/admin/TimelockQueuePanel'; 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 { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useStellarWallet } from '@/hooks/useStellarWallet'; import { isAdminWallet } from '@/utils/adminAccess'; @@ -31,6 +32,7 @@ export default function AdminDashboardPage() { {isAdmin && ( <> + diff --git a/src/services/__tests__/admin.service.test.ts b/src/services/__tests__/admin.service.test.ts index 941f453d..53d994ae 100644 --- a/src/services/__tests__/admin.service.test.ts +++ b/src/services/__tests__/admin.service.test.ts @@ -267,3 +267,59 @@ describe('adminService timelock actions (#1014)', () => { ); }); }); + +describe('adminService treasury', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + }); + + it('loads the exact treasury balance and fee/distribution records', async () => { + const treasury = { accumulatedFeesStroops: '123456789', updatedAt: '2026-09-30T10:00:00Z' }; + const distributions = [{ + id: 'dist-1', epoch: 4, totalDistributedStroops: '10000000', + recipients: [{ address: CALLER_A, amountStroops: '10000000' }], + distributedAt: '2026-09-30T10:00:00Z', transactionHash: 'tx-confirmed', + }]; + const feeEvents = [{ + id: 'fee-1', creatorAddress: CALLER_A, traderAddress: CALLER_B, + amountStroops: '250000', collectedAt: '2026-09-30T09:00:00Z', transactionHash: 'tx-fee', + }]; + mockGet + .mockResolvedValueOnce(fakeApiResponse(treasury)) + .mockResolvedValueOnce(fakeApiResponse(distributions)) + .mockResolvedValueOnce(fakeApiResponse(feeEvents)); + + expect(await adminService.getTreasuryBalance()).toEqual(treasury); + expect(await adminService.getTreasuryDistributions()).toEqual(distributions); + expect(await adminService.getTreasuryFeeEvents()).toEqual(feeEvents); + expect(mockGet.mock.calls.map(([url]) => url)).toEqual([ + '/admin/treasury', + '/admin/treasury/distributions', + '/admin/treasury/fees', + ]); + }); + + it('submits recipient allocations and requires a confirmed on-chain hash', async () => { + const input = { + admin: CALLER_A, + totalAmountStroops: '10000000', + recipients: [{ address: CALLER_B, amountStroops: '10000000' }], + }; + const result = { epoch: 5, transactionHash: 'confirmed-hash' }; + mockPost.mockResolvedValueOnce(fakeApiResponse(result)); + + expect(await adminService.distributeTreasuryFees(input)).toEqual(result); + expect(mockPost).toHaveBeenCalledWith('/admin/treasury/distributions', input); + }); + + it('rejects a distribution response without a transaction confirmation', async () => { + mockPost.mockResolvedValueOnce(fakeApiResponse({ epoch: 5 })); + + await expect(adminService.distributeTreasuryFees({ + admin: CALLER_A, + totalAmountStroops: '10000000', + recipients: [{ address: CALLER_B, amountStroops: '10000000' }], + })).rejects.toMatchObject({ status: 502 }); + }); +}); diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts index 39b1b781..1f4a6b0c 100644 --- a/src/services/admin.service.ts +++ b/src/services/admin.service.ts @@ -10,6 +10,46 @@ export interface OracleCaller { addedAt?: string; } +/** Current protocol treasury state. Monetary values are exact Stellar stroops. */ +export interface TreasuryBalance { + accumulatedFeesStroops: string; + updatedAt?: string; +} + +export interface TreasuryDistributionRecipient { + address: string; + amountStroops: string; +} + +export interface TreasuryDistribution { + id: string; + epoch: number; + totalDistributedStroops: string; + recipients: TreasuryDistributionRecipient[]; + distributedAt: string; + transactionHash: string; +} + +export interface TreasuryFeeCollectedEvent { + id: string; + creatorAddress: string; + traderAddress: string; + amountStroops: string; + collectedAt: string; + transactionHash: string; +} + +export interface TreasuryDistributionInput { + admin: string; + totalAmountStroops: string; + recipients: TreasuryDistributionRecipient[]; +} + +export interface TreasuryDistributionSubmission { + epoch: number; + transactionHash: string; +} + export interface AclContract { address: string; functions: string[]; @@ -533,6 +573,73 @@ class AdminService extends BaseApiService { // and return the parsed history. Until then, return an empty array. return []; } + + /** Read the on-chain protocol fee pool via the admin API. */ + async getTreasuryBalance(): Promise { + try { + const response = await this.api.get>( + '/admin/treasury' + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** Read completed treasury distributions with recipient breakdowns. */ + async getTreasuryDistributions(): Promise { + try { + const response = await this.api.get< + APIResponse + >('/admin/treasury/distributions'); + return response.data.data ?? []; + } catch (error) { + throw this.handleError(error); + } + } + + /** Read recent on-chain FeeCollected event records. */ + async getTreasuryFeeEvents(): Promise { + try { + const response = await this.api.get< + APIResponse + >('/admin/treasury/fees'); + 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. + */ + async distributeTreasuryFees( + input: TreasuryDistributionInput + ): Promise { + try { + const response = await this.api.post< + APIResponse + >('/admin/treasury/distributions', input); + const result = response.data.data; + if ( + !result || + !Number.isSafeInteger(result.epoch) || + result.epoch < 0 || + typeof result.transactionHash !== 'string' || + result.transactionHash.trim() === '' + ) { + throw new ApiError( + 'Treasury distribution was not confirmed by the server.', + 502 + ); + } + return result; + } catch (error) { + if (error instanceof ApiError) throw error; + throw this.handleError(error); + } + } } export const adminService = new AdminService(); diff --git a/src/utils/__tests__/treasury.utils.test.ts b/src/utils/__tests__/treasury.utils.test.ts new file mode 100644 index 00000000..2d0d559c --- /dev/null +++ b/src/utils/__tests__/treasury.utils.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { + formatStroops, + parseXlmToStroops, + validateTreasuryDistribution, +} from '@/utils/treasury.utils'; + +const RECIPIENT_A = Keypair.random().publicKey(); +const RECIPIENT_B = Keypair.random().publicKey(); + +describe('treasury amount utilities', () => { + it('converts XLM decimal strings to exact stroops without floating point', () => { + expect(parseXlmToStroops('12.0000001')).toBe(120_000_001n); + expect(parseXlmToStroops('0.5')).toBe(5_000_000n); + expect(parseXlmToStroops('0')).toBe(0n); + expect(parseXlmToStroops('1.00000001')).toBeNull(); + expect(parseXlmToStroops('01')).toBeNull(); + }); + + it('formats stroops as XLM while trimming insignificant zeroes', () => { + expect(formatStroops('12345678')).toBe('1.2345678 XLM'); + expect(formatStroops('10000000')).toBe('1 XLM'); + expect(formatStroops('0')).toBe('0 XLM'); + }); +}); + +describe('treasury distribution validation', () => { + it('accepts valid unique recipients whose allocations equal the whole balance', () => { + expect(validateTreasuryDistribution('15000000', [ + { address: RECIPIENT_A, amountStroops: '5000000' }, + { address: RECIPIENT_B, amountStroops: '10000000' }, + ])).toBeNull(); + }); + + it('rejects invalid addresses, duplicate addresses, zero values, and mismatched totals', () => { + expect(validateTreasuryDistribution('10000000', [ + { address: 'not-an-address', amountStroops: '10000000' }, + ])).toContain('valid Stellar address'); + expect(validateTreasuryDistribution('10000000', [ + { address: RECIPIENT_A, amountStroops: '5000000' }, + { address: RECIPIENT_A, amountStroops: '5000000' }, + ])).toContain('unique'); + expect(validateTreasuryDistribution('10000000', [ + { address: RECIPIENT_A, amountStroops: '9999999' }, + ])).toContain('equal'); + expect(validateTreasuryDistribution('0', [ + { address: RECIPIENT_A, amountStroops: '1' }, + ])).toContain('no accumulated fees'); + }); +}); diff --git a/src/utils/treasury.utils.ts b/src/utils/treasury.utils.ts new file mode 100644 index 00000000..a58d9309 --- /dev/null +++ b/src/utils/treasury.utils.ts @@ -0,0 +1,64 @@ +import { Address } from '@stellar/stellar-sdk'; +import type { TreasuryDistributionRecipient } from '@/services/admin.service'; + +const STROOPS_PER_XLM = 10_000_000n; + +export function parseXlmToStroops(value: string): bigint | null { + const normalized = value.trim(); + if (!/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(normalized)) return null; + const [whole, fraction = ''] = normalized.split('.'); + return BigInt(whole) * STROOPS_PER_XLM + BigInt(fraction.padEnd(7, '0') || '0'); +} + +export function formatStroops(stroops: string | bigint): string { + let amount: bigint; + try { + amount = typeof stroops === 'bigint' ? stroops : BigInt(stroops); + } catch { + return '—'; + } + const negative = amount < 0n; + const absolute = negative ? -amount : amount; + const whole = absolute / STROOPS_PER_XLM; + const fraction = (absolute % STROOPS_PER_XLM) + .toString() + .padStart(7, '0') + .replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole.toLocaleString()}${fraction ? `.${fraction}` : ''} XLM`; +} + +export function validateTreasuryDistribution( + balanceStroops: string, + recipients: TreasuryDistributionRecipient[] +): string | null { + if (!/^\d+$/.test(balanceStroops)) return 'Treasury balance is unavailable.'; + const balance = BigInt(balanceStroops); + if (balance <= 0n) return 'There are no accumulated fees to distribute.'; + if (recipients.length === 0) return 'Add at least one recipient.'; + + let total = 0n; + const addresses = new Set(); + for (const [index, recipient] of recipients.entries()) { + const address = recipient.address.trim(); + try { + Address.fromString(address); + } catch { + return `Recipient ${index + 1} must be a valid Stellar address.`; + } + const normalizedAddress = address.toLowerCase(); + if (addresses.has(normalizedAddress)) { + return 'Recipient addresses must be unique.'; + } + addresses.add(normalizedAddress); + + if (!/^\d+$/.test(recipient.amountStroops)) { + return `Recipient ${index + 1} must have a valid amount.`; + } + const amount = BigInt(recipient.amountStroops); + if (amount <= 0n) return `Recipient ${index + 1} amount must be greater than zero.`; + total += amount; + } + + if (total !== balance) return 'Recipient amounts must equal the accumulated treasury balance.'; + return null; +} From 999055788d172be921dfa2741d9428e8ec09ab5f Mon Sep 17 00:00:00 2001 From: tek-lizzy Date: Wed, 30 Sep 2026 18:16:22 +0100 Subject: [PATCH 2/2] feat: add platform-wide runtime error boundaries --- docs/error-handling-conventions.md | 8 + src/components/common/AppErrorBoundary.tsx | 42 ++- .../common/CreatorPageErrorBoundary.tsx | 5 +- .../common/KeyDetailPageErrorBoundary.tsx | 56 ++-- src/components/common/RouteErrorBoundary.tsx | 103 ++++++++ .../common/SectionErrorBoundary.tsx | 35 ++- .../common/TradePanelErrorBoundary.tsx | 39 ++- .../__tests__/AppErrorBoundary.test.tsx | 74 +++--- .../__tests__/RouteErrorBoundary.test.tsx | 67 +++++ .../SectionErrorBoundary.retry.test.tsx | 44 ++++ src/pages/AdminDashboardPage.tsx | 25 +- src/pages/CreatorDetailPage.tsx | 239 +++++++++++------- src/pages/HomePage.tsx | 33 ++- src/pages/MarketplacePage.tsx | 224 +++++++++------- src/pages/ProfilePage.tsx | 141 ++++++----- src/routes.tsx | 42 ++- 16 files changed, 833 insertions(+), 344 deletions(-) create mode 100644 src/components/common/RouteErrorBoundary.tsx create mode 100644 src/components/common/__tests__/RouteErrorBoundary.test.tsx create mode 100644 src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx diff --git a/docs/error-handling-conventions.md b/docs/error-handling-conventions.md index e8859e9a..92997115 100644 --- a/docs/error-handling-conventions.md +++ b/docs/error-handling-conventions.md @@ -35,6 +35,14 @@ to be able to do. | --- | ------------------ | ----------------------------- | ----------------- | -------------------------------------------------- | | 6 | **Network banner** | Connected wallet, wrong chain | Persistent notice | `NetworkMismatchBanner` from `@/components/common` | +For route-level runtime failures, wrap major routes with `RouteErrorBoundary`; +for independent widgets within a page, use `SectionErrorBoundary`. Both provide +a contextual fallback, retry by remounting the failed subtree, and a support +link. Caught exception details and diagnostics are development-only and are +not rendered or logged in production builds. These boundaries handle React +render/lifecycle errors; query and mutation failures should continue to use +the inline and toast patterns below. + --- ## Decision Flowchart diff --git a/src/components/common/AppErrorBoundary.tsx b/src/components/common/AppErrorBoundary.tsx index fd5345b6..1a35259f 100644 --- a/src/components/common/AppErrorBoundary.tsx +++ b/src/components/common/AppErrorBoundary.tsx @@ -8,6 +8,7 @@ interface Props { interface State { hasError: boolean; + resetKey: number; } /** @@ -16,25 +17,30 @@ interface State { * CreatorPageErrorBoundary, etc). This is the last line of defense before * React would otherwise unmount the whole tree to a blank screen. * - * A full reload is used for recovery rather than resetting local state: - * an error this high up means the app-level state that produced it is - * suspect, so a fresh mount is safer than trying to resume it. + * The retry action remounts the complete app subtree so the app can recover + * without requiring a browser reload. */ class AppErrorBoundary extends Component { public state: State = { hasError: false, + resetKey: 0, }; - public static getDerivedStateFromError(): State { + public static getDerivedStateFromError(): Partial { return { hasError: true }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('Uncaught error at app root:', error, errorInfo); + if (import.meta.env.DEV) { + console.error('Uncaught error at app root:', error, errorInfo); + } } - private handleReload = () => { - this.setState({ hasError: false }); + private handleRetry = () => { + this.setState(state => ({ + hasError: false, + resetKey: state.resetKey + 1, + })); }; public render() { @@ -54,23 +60,35 @@ class AppErrorBoundary extends Component { Something went wrong

- The app hit an unexpected error and couldn't continue. - Reloading the page usually fixes this. + The app hit an unexpected error and couldn't continue. Try + again, or contact support if the issue persists.

+ + Contact support + ); } - return this.props.children; + return ( +
+ {this.props.children} +
+ ); } } diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx index 8b7edf45..a7d54cfd 100644 --- a/src/components/common/CreatorPageErrorBoundary.tsx +++ b/src/components/common/CreatorPageErrorBoundary.tsx @@ -47,7 +47,10 @@ class CreatorPageErrorBoundary extends Component { aria-live="assertive" >
-
); } - return this.props.children; + return ( +
+ {this.props.children} +
+ ); } } diff --git a/src/components/common/__tests__/AppErrorBoundary.test.tsx b/src/components/common/__tests__/AppErrorBoundary.test.tsx index d8d3c8f8..aa9fbe8f 100644 --- a/src/components/common/__tests__/AppErrorBoundary.test.tsx +++ b/src/components/common/__tests__/AppErrorBoundary.test.tsx @@ -1,37 +1,39 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -import AppErrorBoundary from "@/components/common/AppErrorBoundary"; - -describe("AppErrorBoundary", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("renders the fallback and recovers when the retry action resets the boundary", async () => { - const user = userEvent.setup(); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - let shouldThrow = true; - - const FlakyChild = () => { - if (shouldThrow) throw new Error("Simulated app crash"); - return

Recovered content

; - }; - - render( - - - , - ); - - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("Something went wrong")).toBeInTheDocument(); - - shouldThrow = false; - await user.click(screen.getByRole("button", { name: /reload page/i })); - - expect(screen.getByText("Recovered content")).toBeInTheDocument(); - expect(consoleErrorSpy).toHaveBeenCalled(); - }); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AppErrorBoundary from '@/components/common/AppErrorBoundary'; + +describe('AppErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the fallback and recovers when the retry action resets the boundary', async () => { + const user = userEvent.setup(); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + let shouldThrow = true; + + const FlakyChild = () => { + if (shouldThrow) throw new Error('Simulated app crash'); + return

Recovered content

; + }; + + render( + + + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + shouldThrow = false; + await user.click(screen.getByRole('button', { name: /retry/i })); + + expect(screen.getByText('Recovered content')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); }); diff --git a/src/components/common/__tests__/RouteErrorBoundary.test.tsx b/src/components/common/__tests__/RouteErrorBoundary.test.tsx new file mode 100644 index 00000000..1a89c0e6 --- /dev/null +++ b/src/components/common/__tests__/RouteErrorBoundary.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import RouteErrorBoundary from '@/components/common/RouteErrorBoundary'; + +let shouldThrow = true; + +function CrashableRoute() { + if (shouldThrow) throw new Error('private runtime detail'); + return

Recovered route content

; +} + +describe('RouteErrorBoundary', () => { + beforeEach(() => { + shouldThrow = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('shows a contextual fallback with retry and support actions, then remounts the route', () => { + render( + + + + ); + + expect(screen.getByTestId('route-error-fallback')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: /portfolio couldn't load/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /contact support/i }) + ).toHaveAttribute( + 'href', + 'https://github.com/accesslayerorg/accesslayer-client/issues' + ); + + shouldThrow = false; + fireEvent.click(screen.getByTestId('route-error-retry')); + + expect(screen.getByText('Recovered route content')).toBeInTheDocument(); + expect( + screen.queryByTestId('route-error-fallback') + ).not.toBeInTheDocument(); + }); + + it('does not render error details in production builds', () => { + render( + + + + ); + + if (import.meta.env.DEV) { + expect(screen.getByTestId('route-error-details')).toHaveTextContent( + 'private runtime detail' + ); + } else { + expect( + screen.queryByTestId('route-error-details') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('private runtime detail') + ).not.toBeInTheDocument(); + } + }); +}); diff --git a/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx b/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx new file mode 100644 index 00000000..54cf5e5e --- /dev/null +++ b/src/components/common/__tests__/SectionErrorBoundary.retry.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +let shouldThrow = true; + +function CrashableSection() { + if (shouldThrow) throw new Error('section failed'); + return

Recovered section

; +} + +describe('SectionErrorBoundary retry', () => { + beforeEach(() => { + shouldThrow = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('keeps healthy siblings available and remounts only the failed section on retry', () => { + render( +
+

Healthy sibling section

+ + + +
+ ); + + expect(screen.getByText('Healthy sibling section')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /contact support/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /retry/i }) + ).toBeInTheDocument(); + + shouldThrow = false; + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + + expect(screen.getByText('Healthy sibling section')).toBeInTheDocument(); + expect(screen.getByText('Recovered section')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx index 2b0dee24..93d0d8ff 100644 --- a/src/pages/AdminDashboardPage.tsx +++ b/src/pages/AdminDashboardPage.tsx @@ -8,6 +8,7 @@ import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useStellarWallet } from '@/hooks/useStellarWallet'; import { isAdminWallet } from '@/utils/adminAccess'; import { Navigate } from 'react-router'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; export default function AdminDashboardPage() { useNavigationTiming('admin-dashboard'); @@ -32,12 +33,24 @@ export default function AdminDashboardPage() { {isAdmin && ( <> - - - - - - + + + + + + + + + + + + + + + + + + )} {!isConnected && ( diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 76011490..a55ab67f 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -69,11 +69,19 @@ import PerformanceBondPanel from '@/components/common/PerformanceBondPanel'; import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge'; import ShareModal from '@/components/common/ShareModal'; -import { useTradeCooldownStatus, invalidateTradeCooldownStatus, resolveActiveTradeCooldown } from '@/hooks/useTradeCooldownStatus'; -import { isActiveCooldown, type ActiveTradeCooldown } from '@/utils/tradeCooldown.utils'; +import { + useTradeCooldownStatus, + invalidateTradeCooldownStatus, + resolveActiveTradeCooldown, +} from '@/hooks/useTradeCooldownStatus'; +import { + isActiveCooldown, + type ActiveTradeCooldown, +} from '@/utils/tradeCooldown.utils'; import TradeCooldownButton from '@/components/common/TradeCooldownButton'; import KeyHolderList from '@/components/common/KeyHolderList'; import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -85,8 +93,9 @@ function CreatorDetailPageContent() { const [recentSettlement, setRecentSettlement] = useState(null); const [shareModalOpen, setShareModalOpen] = useState(false); - const [lastPurchasedAmount, setLastPurchasedAmount] = - useState(null); + const [lastPurchasedAmount, setLastPurchasedAmount] = useState< + number | null + >(null); const [deprecationDismissed, setDeprecationDismissed] = useState(false); const { data: creator, @@ -172,7 +181,6 @@ function CreatorDetailPageContent() { const performanceBond = performanceBondData ?? creator?.performanceBond ?? null; - // Pre-launch auction phase (#924): a live tick that drives the countdown // and flips the page back to the bonding curve view once the auction ends. const { phase: auctionPhase } = useAuctionPhase({ @@ -193,11 +201,10 @@ function CreatorDetailPageContent() { ) || (creator?.instructorId && creator.instructorId.toUpperCase() === userAddress.toUpperCase()) - ) + ) : false; const isLockedOut = isWhitelistGateActive && !isUserWhitelisted; - // On-chain metadata fetch (#1033) const { data: onChainMetadata, @@ -209,11 +216,22 @@ function CreatorDetailPageContent() { const isFallbackActive = isOnChainError || !onChainMetadata; const metadata = onChainMetadata ?? {}; - const displayName = metadata.name || creator?.title || creator?.name || 'Unnamed creator'; + const displayName = + metadata.name || creator?.title || creator?.name || 'Unnamed creator'; const displaySymbol = metadata.symbol; - const displayDescription = metadata.description || creator?.description || creator?.bio; - const rawAvatar = metadata.image || metadata.imageCid || metadata.image_cid || metadata.ipfsCid || metadata.ipfs_cid || metadata.avatarUri || metadata.avatar_uri || metadata.cid; - const displayAvatar = resolveIpfsUrl(rawAvatar) || creator?.avatarUri || creator?.thumbnail; + const displayDescription = + metadata.description || creator?.description || creator?.bio; + const rawAvatar = + metadata.image || + metadata.imageCid || + metadata.image_cid || + metadata.ipfsCid || + metadata.ipfs_cid || + metadata.avatarUri || + metadata.avatar_uri || + metadata.cid; + const displayAvatar = + resolveIpfsUrl(rawAvatar) || creator?.avatarUri || creator?.thumbnail; // Track stale data indicator (including fallback active state) const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( @@ -341,7 +359,8 @@ function CreatorDetailPageContent() { const statItems = [ { - label: auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price', + label: + auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price', value: auctionPhase === 'active' ? auctionStatValue @@ -466,34 +485,37 @@ function CreatorDetailPageContent() { className="mt-3 size-11 shrink-0 sm:size-9" /> - {/* Historical Price Chart */} - - + + + {/* 4 Stat Cards */}
- {/* Key Stats Panel (#952) */} - + + + {/* Performance Bond Status Panel (#975) */} - + + + {/* Deprecation Notice and Buy Action on Key Detail Page */} {isKeyDeprecated(creator) && (
@@ -517,8 +539,8 @@ function CreatorDetailPageContent() { {isKeyDeprecated(creator) ? 'Key is deprecated. New buys are disabled.' : isLockedOut - ? 'Early access is restricted to approved whitelisted wallets.' - : 'Purchase keys for this creator.'} + ? 'Early access is restricted to approved whitelisted wallets.' + : 'Purchase keys for this creator.'}

{/* Configurable bid-ask spread between buy and sell price (#951) */} )}
- - {/* Pre-Launch Auction Phase (#924) */} {auctionPhase !== 'inactive' && ( )} - - {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} {userAddress && !isTradeCooldownActive && ( @@ -598,7 +616,6 @@ function CreatorDetailPageContent() { }} /> - {isTwapLoading ? (
- {/* Price Curve Chart */}
Price Curve - +
- + + + {/* Buy Simulation Tool */} - - {/* TWAP for the live curve — not meaningful during the pre-launch auction */} {auctionPhase !== 'active' && (isTwapLoading ? ( -
-
+
+
+ + +
) : twapPrice != null ? ( -
+
- TWAP (24h) + + TWAP (24h) + - +
-
{formatDisplayKeyPrice(twapPrice)}
+
+ {formatDisplayKeyPrice(twapPrice)} +
- {twapDelta != null && {twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot} + {twapDelta != null && ( + + {twapDelta < 0 ? '▼' : '▲'}{' '} + {formatDisplayKeyPrice(Math.abs(twapDelta))} vs + spot + + )}
) : null)} - {/* Staking Rewards */} - {/* Price Chart — hidden during the pre-launch auction phase (#924) and shown again once the auction closes and the curve activates */} {auctionPhase !== 'active' && ( @@ -729,7 +782,6 @@ function CreatorDetailPageContent() { />
)} - {/* Buy Simulation Tool — likewise only once the curve is live */} {auctionPhase !== 'active' && ( )} - - {/* Holder Concentration */}
Holder Concentration - + + +
{/* Fee Structure */}
@@ -768,13 +820,15 @@ function CreatorDetailPageContent() {
{/* Co-Creator Section */}{' '} - + + + {/* Key Holders */}
Key Holders - { - void fetchNextPage(); - }} - /> + + { + void fetchNextPage(); + }} + /> +
- {/* Gated Content */}

@@ -805,17 +860,20 @@ function CreatorDetailPageContent() { >

- 🎉 Welcome to the exclusive content section! Here you can access premium videos, articles, and perks from {creator.title || creator.name || 'this creator'}. + 🎉 Welcome to the exclusive content section! Here you + can access premium videos, articles, and perks from{' '} + {creator.title || creator.name || 'this creator'}.

-

Activity

- + + +
{/* Key Buyback Modal (#923) */} {isKeyDeprecated(creator) && ( @@ -825,14 +883,15 @@ function CreatorDetailPageContent() { creatorId={creator.id} creatorTitle={creator.title || creator.name || 'Creator Key'} holdingsCount={holdingsCount} - buybackPriceStroops={resolveCreatorKeyPriceStroops(creator) ?? 0} + buybackPriceStroops={ + resolveCreatorKeyPriceStroops(creator) ?? 0 + } userAddress={userAddress} onSettled={receipt => { setRecentSettlement(receipt); }} /> )} - {creator && ( ); -} \ No newline at end of file +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 04641bb3..11b0b11b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -11,6 +11,7 @@ import RecentlyViewedSection from '../components/home/RecentlyViewedSection'; import { useNavigationTiming } from '../hooks/useNavigationTiming'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import { useLocation } from 'react-router'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; export default function HomePage() { const location = useLocation(); @@ -25,14 +26,30 @@ export default function HomePage() { <>
- - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index e2f23d3d..4bb6f319 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -16,6 +16,7 @@ import SearchBar from '@/components/common/SearchBar'; import SectionDivider from '@/components/common/SectionDivider'; import StickyFilterBar from '@/components/common/StickyFilterBar'; import { Button } from '@/components/ui/button'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import { MARKETPLACE_SORT_OPTIONS, sortCreatorsByOption, @@ -39,7 +40,12 @@ const VALID_SORT_OPTIONS: CourseSortOption[] = [ ]; function creatorMatchesSearch(creator: Course, query: string): boolean { - return [creator.title, creator.name, creator.instructorId, creator.socialHandle] + return [ + creator.title, + creator.name, + creator.instructorId, + creator.socialHandle, + ] .filter(Boolean) .some(field => field!.toLowerCase().includes(query)); } @@ -62,7 +68,8 @@ export default function MarketplacePage() { const initialSortParam = searchParams.get('sort'); const initialSort: CourseSortOption = - initialSortParam && VALID_SORT_OPTIONS.includes(initialSortParam as CourseSortOption) + initialSortParam && + VALID_SORT_OPTIONS.includes(initialSortParam as CourseSortOption) ? (initialSortParam as CourseSortOption) : 'volume_desc'; @@ -107,14 +114,18 @@ export default function MarketplacePage() { }; const hasActiveFilters = - Boolean(trimmedSearch) || supplyTier !== 'all' || sortOption !== 'volume_desc'; + Boolean(trimmedSearch) || + supplyTier !== 'all' || + sortOption !== 'volume_desc'; // #918 — sort and supply-tier filter are applied client-side over the // pages already fetched from the server, so they work instantly and never // require a fresh network fetch. const visibleCreators = useMemo(() => { const searched = trimmedSearch - ? creators.filter(creator => creatorMatchesSearch(creator, trimmedSearch)) + ? creators.filter(creator => + creatorMatchesSearch(creator, trimmedSearch) + ) : creators; const tierFiltered = searched.filter(creator => matchesSupplyTier(creator.creatorShareSupply, supplyTier) @@ -150,7 +161,11 @@ export default function MarketplacePage() {
- + )} - {isRefreshing ? 'Refreshing live prices…' : 'Prices refresh every 30s'} + {isRefreshing + ? 'Refreshing live prices…' + : 'Prices refresh every 30s'}
-
+
@@ -223,12 +245,17 @@ export default function MarketplacePage() { data-testid="marketplace-sort-select" value={sortOption} onChange={e => - handleSortChange(e.target.value as CourseSortOption) + handleSortChange( + e.target.value as CourseSortOption + ) } className="h-9 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" > {MARKETPLACE_SORT_OPTIONS.map(option => ( - ))} @@ -246,7 +273,9 @@ export default function MarketplacePage() { data-testid="marketplace-supply-tier-select" value={supplyTier} onChange={e => - setSupplyTier(e.target.value as SupplyTierFilter) + setSupplyTier( + e.target.value as SupplyTierFilter + ) } className="h-9 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" > @@ -264,96 +293,107 @@ export default function MarketplacePage() { - - {isLoadingFirstPage ? ( -
- -
- ) : error && creators.length === 0 ? ( - - ) : visibleCreators.length === 0 ? ( -
- {creators.length === 0 ? ( - - ) : ( - - )} -
- ) : ( -
-
- {visibleCreators.map(creator => ( - + {isLoadingFirstPage ? ( +
+ +
+ ) : error && creators.length === 0 ? ( + + ) : visibleCreators.length === 0 ? ( +
+ {creators.length === 0 ? ( + - ))} + ) : ( + + )}
- - {isFetchingNextPage && ( -
- + ) : ( +
+
+ {visibleCreators.map(creator => ( + + ))}
- )} - {hasMore && ( - - )} - + )} + +
); -} \ No newline at end of file +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 81fd4fbe..947b5eb8 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,7 +1,4 @@ -import { - useMemo, - useState, -} from 'react'; +import { useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { useAccount } from 'wagmi'; import { @@ -23,6 +20,7 @@ import AtomicSwapHistory from '@/components/common/AtomicSwapHistory'; import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim'; import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable'; import WalletActivityFeed from '@/components/common/WalletActivityFeed'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import TruncatedAddress from '@/components/common/TruncatedAddress'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import { useProfileStore } from '@/hooks/useProfileStore'; @@ -116,7 +114,8 @@ export default function ProfilePage() { ); const [isShareCopied, setIsShareCopied] = useState(false); - const profileWallet = publicWallet?.trim() || connectedAddress || DEMO_WALLET; + const profileWallet = + publicWallet?.trim() || connectedAddress || DEMO_WALLET; const isOwnProfile = !publicWallet ? true : isOwnWallet(connectedAddress ?? null, publicWallet); @@ -169,8 +168,9 @@ export default function ProfilePage() { const heldKeyNames = useMemo(() => { const map: Record = {}; for (const holding of holdingsQuery.data ?? []) { - map[holding.creatorId] = - creators.find(c => c.id === holding.creatorId)?.title; + map[holding.creatorId] = creators.find( + c => c.id === holding.creatorId + )?.title; } return map; }, [holdingsQuery.data, creators]); @@ -314,15 +314,17 @@ export default function ProfilePage() {

- + + + {isOwnProfile && (
@@ -357,15 +359,18 @@ export default function ProfilePage() { Vesting Schedules

- Reserved key allocations unlocking on a cliff-then-linear - schedule. Claim vested amounts once the cliff passes. + Reserved key allocations unlocking on a + cliff-then-linear schedule. Claim vested amounts once + the cliff passes.

- + + + )} @@ -383,20 +388,22 @@ export default function ProfilePage() { Staking

- Keys currently locked in the staking contract with - their unlock timers and accrued rewards. + Keys currently locked in the staking contract with their + unlock timers and accrued rewards.

- {!isOwnProfile ? ( - - ) : ( + {!isOwnProfile ? ( + + + + ) : ( <> {/* Sub-tab navigation within Staking section */}
- + + +
) : activeStakingSubTab === 'claim' ? (
- + + +
) : (
- + + +
)} @@ -481,9 +494,11 @@ export default function ProfilePage() { aria-labelledby="profile-tab-liquidity" data-testid="portfolio-liquidity-panel" > - + + + )} @@ -503,13 +518,16 @@ export default function ProfilePage() {

A full audit trail of past buys and sells

- - - - )} + {' '} + + + + + + )} {/* Atomic swap history panel (#979) */} {activeTab === 'atomic-swaps' && ( @@ -529,7 +547,9 @@ export default function ProfilePage() {

- + + + )} @@ -548,11 +568,14 @@ export default function ProfilePage() { Wallet Activity

- All trading, staking, and governance events for your wallet + All trading, staking, and governance events for your + wallet

- + + + )} diff --git a/src/routes.tsx b/src/routes.tsx index ad27784f..045f9adb 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import RootLayout from './components/common/RootLayout'; import HomePage from './pages/HomePage'; import NotFoundPage from './pages/NotFoundPage'; @@ -26,6 +27,13 @@ import StatusPage from './pages/StatusPage'; import HolderLeaderboardPage from './pages/HolderLeaderboardPage'; import BundlesPage from './pages/BundlesPage'; import BundleDetailPage from './pages/BundleDetailPage'; +import RouteErrorBoundary from './components/common/RouteErrorBoundary'; + +function withRouteErrorBoundary(element: ReactNode, routeName: string) { + return ( + {element} + ); +} export const routes = [ { @@ -34,15 +42,15 @@ export const routes = [ children: [ { path: '/', - element: , + element: withRouteErrorBoundary(, 'Marketplace'), }, { path: '/creators', - element: , + element: withRouteErrorBoundary(, 'Marketplace'), }, { path: '/marketplace', - element: , + element: withRouteErrorBoundary(, 'Marketplace'), }, { path: '/bundles', @@ -66,17 +74,26 @@ export const routes = [ }, { path: '/creator/:id', - element: , + element: withRouteErrorBoundary( + , + 'Key detail' + ), }, { path: '/creators/:id', - element: , + element: withRouteErrorBoundary( + , + 'Key detail' + ), }, { path: '/keys/:id', - element: , - }, - { + element: withRouteErrorBoundary( + , + 'Key detail' + ), + }, + { path: '/creator/:id/leaderboard', element: , }, @@ -126,11 +143,11 @@ export const routes = [ }, { path: '/profile', - element: , + element: withRouteErrorBoundary(, 'Portfolio'), }, { path: '/profile/:wallet', - element: , + element: withRouteErrorBoundary(, 'Portfolio'), }, { path: '/following', @@ -170,7 +187,10 @@ export const routes = [ }, { path: '/admin/dashboard', - element: , + element: withRouteErrorBoundary( + , + 'Admin dashboard' + ), }, { path: '/revenue-distribution',