+ {this.props.routeName} couldn't load +
++ An unexpected error interrupted this page. Retry, or + contact support if the problem continues. +
+ {import.meta.env.DEV && ( +
+ {this.state.error.message}
+
+ )}
+ 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/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/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 ( +
Treasury data could not be loaded.
+ +Unallocated protocol fees available for distribution.
++ {formatStroops(balance.data.accumulatedFeesStroops)} +
+ )} + {balance.data?.updatedAt &&Updated {timestamp(balance.data.updatedAt)}
} +Completed distribution epochs, recipients, and confirmed transaction dates.
+ {distributions.isLoading ?No treasury distributions yet.
+ ) : ( +| Epoch | Total distributed | Recipients | Date | +
|---|---|---|---|
| {item.epoch} | +{formatStroops(item.totalDistributedStroops)} | +{item.recipients.map(recipient => `${recipient.address} — ${formatStroops(recipient.amountStroops)}`).join('; ')} | +{timestamp(item.distributedAt)} | +
Recent per-trade FeeCollected events added to the treasury.
+ {feeEvents.isLoading ?No fee collection events found.
+ ) : ( +| Date | Creator | Trader | Fee collected | +
|---|---|---|---|
| {timestamp(item.collectedAt)} | +{item.creatorAddress} | +{item.traderAddress} | +{formatStroops(item.amountStroops)} | +
- 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 ( +- An unexpected error occurred. You can reload the page or return - to the marketplace to continue browsing. + An unexpected error occurred. You can reload the page or + return to the marketplace to continue browsing.
- {this.state.error && ( + {import.meta.env.DEV && this.state.error && ({this.state.error.message}
@@ -77,11 +87,11 @@ class KeyDetailPageErrorBoundary extends Component+ An unexpected error interrupted this page. Retry, or + contact support if the problem continues. +
+ {import.meta.env.DEV && ( +
+ {this.state.error.message}
+
+ )}
+
@@ -81,11 +94,23 @@ class SectionErrorBoundary extends Component
- {this.props.fallbackMessage ?? 'Something went wrong inside the trade panel'} + {this.props.fallbackMessage ?? + 'Something went wrong inside the trade panel'}
- The rest of the marketplace is still running. Click retry below to attempt remounting the trade panel. + The rest of the marketplace is still running. Click retry + below to attempt remounting the trade panel.
Recovered content
; - }; - - render( -Recovered content
; + }; + + render( +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( +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
+- 🎉 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'}.
- 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.
- 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 ? ( -A full audit trail of past buys and sells
-- All trading, staking, and governance events for your wallet + All trading, staking, and governance events for your + wallet
-