From b7a284816b97159254a5e66f109a1572e84fc528 Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:50:04 +0100 Subject: [PATCH 1/3] feat(holders): add staking-aware Staked/Liquid columns to holder list (#814) Fetch a per-holder stakedQuantity from the holders endpoint and surface the liquid vs. staked split in the holder list: - KeyHolderEntry / KeyHolder gain an optional stakedQuantity field - rankKeyHolders derives stakedQuantity (clamped to [0, keyCount]) and liquidQuantity (keyCount - stakedQuantity) per ranked entry; ranking stays keyed on total quantity so staked keys still count toward rank - KeyHolderList renders Staked, Liquid and Total columns with a header row and shows a staking badge on rows where stakedQuantity > 0 - CreatorDetailPage sample holders carry demo staked values Co-Authored-By: Claude Sonnet 5 --- src/components/common/KeyHolderList.tsx | 57 +++++++++++++-- .../common/__tests__/KeyHolderList.test.tsx | 73 +++++++++++++++++++ src/pages/CreatorDetailPage.tsx | 10 +-- src/services/course.service.ts | 7 ++ .../__tests__/keyHolderRanking.utils.test.ts | 42 +++++++++++ src/utils/keyHolderRanking.utils.ts | 37 +++++++++- 6 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/components/common/KeyHolderList.tsx b/src/components/common/KeyHolderList.tsx index 0160935d..c73f8695 100644 --- a/src/components/common/KeyHolderList.tsx +++ b/src/components/common/KeyHolderList.tsx @@ -1,3 +1,4 @@ +import { Lock } from 'lucide-react'; import { formatHolderCount, formatPercent } from '@/utils/numberFormat.utils'; import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; @@ -19,10 +20,12 @@ function truncateAddress(address: string): string { } /** - * Ranks investors by key count and shows each holder's share of the total - * supply held across the list. When infinite scroll props are provided, a - * sentinel triggers the next page fetch and a loading spinner is shown - * while the next page loads. When no more pages remain, "All holders + * Ranks investors by total quantity held (liquid + staked) and shows each + * holder's share of the supply held across the list, alongside a per-row + * Staked / Liquid split so true liquid supply is visible at a glance. Rows + * with staked keys carry a staking badge. When infinite scroll props are + * provided, a sentinel triggers the next page fetch and a loading spinner is + * shown while the next page loads. When no more pages remain, "All holders * loaded" is displayed at the bottom. */ const KeyHolderList: React.FC = ({ @@ -51,6 +54,18 @@ const KeyHolderList: React.FC = ({ return ( <> +
+ Holder +
+ Staked + Liquid + Total + Share +
+
    {ranked.map(holder => (
  1. = ({ {holder.displayName} )} + {holder.stakedQuantity > 0 && ( + + + )} -
    - - {formatHolderCount(holder.keyCount)} keys +
    + + {formatHolderCount(holder.stakedQuantity)} + + + {formatHolderCount(holder.liquidQuantity)} + + + {formatHolderCount(holder.keyCount)} {formatPercent(holder.sharePercent, { maximumFractionDigits: 1 })} diff --git a/src/components/common/__tests__/KeyHolderList.test.tsx b/src/components/common/__tests__/KeyHolderList.test.tsx index a17f837d..4d3b6f5e 100644 --- a/src/components/common/__tests__/KeyHolderList.test.tsx +++ b/src/components/common/__tests__/KeyHolderList.test.tsx @@ -81,3 +81,76 @@ describe('KeyHolderList', () => { expect(screen.getByLabelText('Rank 1')).toBeInTheDocument(); }); }); + +function stakedHolder( + id: string, + displayName: string, + keyCount: number, + stakedQuantity: number +): KeyHolder { + return { id, displayName, keyCount, stakedQuantity }; +} + +describe('KeyHolderList staking columns (#814)', () => { + it('renders Staked and Liquid values per holder', () => { + render( + + ); + + expect(screen.getByTestId('key-holder-staked')).toHaveTextContent('8'); + expect(screen.getByTestId('key-holder-liquid')).toHaveTextContent('12'); + expect(screen.getByTestId('key-holder-key-count')).toHaveTextContent('20'); + }); + + it('shows a staking badge only on rows with stakedQuantity greater than zero', () => { + render( + + ); + + const badges = screen.getAllByTestId('key-holder-staking-badge'); + expect(badges).toHaveLength(1); + + const rows = screen.getAllByTestId('key-holder-row'); + const stakerRow = rows.find(r => within(r).queryByText('Staker'))!; + const plainRow = rows.find(r => within(r).queryByText('Plain'))!; + expect(within(stakerRow).getByTestId('key-holder-staking-badge')).toBeInTheDocument(); + expect(within(plainRow).queryByTestId('key-holder-staking-badge')).not.toBeInTheDocument(); + }); + + it('renders 0 for a holder with no staked keys and no badge', () => { + render(); + + expect(screen.getByTestId('key-holder-staked')).toHaveTextContent('0'); + expect(screen.getByTestId('key-holder-liquid')).toHaveTextContent('10'); + expect(screen.queryByTestId('key-holder-staking-badge')).not.toBeInTheDocument(); + }); + + it('treats holders without a stakedQuantity field as fully liquid', () => { + render(); + + expect(screen.getByTestId('key-holder-staked')).toHaveTextContent('0'); + expect(screen.getByTestId('key-holder-liquid')).toHaveTextContent('10'); + }); + + it('orders rows by total quantity descending regardless of staked split', () => { + render( + + ); + + const rows = screen.getAllByTestId('key-holder-row'); + expect( + rows.map(row => within(row).getByText(/^(Alice|Bob|Cara)$/).textContent) + ).toEqual(['Bob', 'Cara', 'Alice']); + }); +}); diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index f9c6c521..dd053224 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -101,11 +101,11 @@ function CreatorDetailPageContent() { })); const defaultHolders = [ - { id: 'h1', displayName: 'Early Adopter', keyCount: 25, sharePercent: 25 }, - { id: 'h2', displayName: 'Alpha Collector', keyCount: 15, sharePercent: 15 }, - { id: 'h3', displayName: 'Key Holder 3', keyCount: 10, sharePercent: 10 }, - { id: 'h4', displayName: 'Key Holder 4', keyCount: 8, sharePercent: 8 }, - { id: 'h5', displayName: 'Key Holder 5', keyCount: 5, sharePercent: 5 }, + { id: 'h1', displayName: 'Early Adopter', keyCount: 25, stakedQuantity: 18 }, + { id: 'h2', displayName: 'Alpha Collector', keyCount: 15, stakedQuantity: 5 }, + { id: 'h3', displayName: 'Key Holder 3', keyCount: 10, stakedQuantity: 0 }, + { id: 'h4', displayName: 'Key Holder 4', keyCount: 8, stakedQuantity: 0 }, + { id: 'h5', displayName: 'Key Holder 5', keyCount: 5, stakedQuantity: 2 }, ]; return ( diff --git a/src/services/course.service.ts b/src/services/course.service.ts index f78c0b70..2b34469e 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -69,7 +69,14 @@ export interface KeyHolderEntry { id: string; displayName: string; walletAddress: string; + /** Total keys held by this holder, including any that are staked. */ keyCount: number; + /** + * How many of `keyCount` are currently locked in the staking contract. + * Absent on responses from the pre-staking holders endpoint; callers + * should treat a missing value as `0`. + */ + stakedQuantity?: number; } /** Cursor-paginated response envelope for the key holders endpoint. */ diff --git a/src/utils/__tests__/keyHolderRanking.utils.test.ts b/src/utils/__tests__/keyHolderRanking.utils.test.ts index 6384b69e..e710ee10 100644 --- a/src/utils/__tests__/keyHolderRanking.utils.test.ts +++ b/src/utils/__tests__/keyHolderRanking.utils.test.ts @@ -69,3 +69,45 @@ describe('rankKeyHolders', () => { expect(input).toEqual(inputCopy); }); }); + +describe('rankKeyHolders staking split (#814)', () => { + it('defaults stakedQuantity to 0 and liquidQuantity to keyCount when absent', () => { + const [entry] = rankKeyHolders([holder('a', 12)]); + expect(entry!.stakedQuantity).toBe(0); + expect(entry!.liquidQuantity).toBe(12); + }); + + it('splits keyCount into staked and liquid portions', () => { + const [entry] = rankKeyHolders([ + { id: 'a', displayName: 'A', keyCount: 20, stakedQuantity: 8 }, + ]); + expect(entry!.stakedQuantity).toBe(8); + expect(entry!.liquidQuantity).toBe(12); + }); + + it('ranks a fully staked holder by total quantity, not liquid quantity', () => { + const ranked = rankKeyHolders([ + { id: 'liquid', displayName: 'Liquid', keyCount: 15, stakedQuantity: 0 }, + { id: 'staked', displayName: 'Staked', keyCount: 30, stakedQuantity: 30 }, + ]); + expect(ranked.map(h => h.id)).toEqual(['staked', 'liquid']); + expect(ranked[0]!.rank).toBe(1); + expect(ranked[0]!.liquidQuantity).toBe(0); + }); + + it('clamps a staked value larger than keyCount and never yields negative liquid', () => { + const [entry] = rankKeyHolders([ + { id: 'a', displayName: 'A', keyCount: 10, stakedQuantity: 999 }, + ]); + expect(entry!.stakedQuantity).toBe(10); + expect(entry!.liquidQuantity).toBe(0); + }); + + it('treats a negative or non-finite staked value as 0', () => { + const [neg] = rankKeyHolders([ + { id: 'a', displayName: 'A', keyCount: 10, stakedQuantity: -4 }, + ]); + expect(neg!.stakedQuantity).toBe(0); + expect(neg!.liquidQuantity).toBe(10); + }); +}); diff --git a/src/utils/keyHolderRanking.utils.ts b/src/utils/keyHolderRanking.utils.ts index 5f9a22ac..3dde587c 100644 --- a/src/utils/keyHolderRanking.utils.ts +++ b/src/utils/keyHolderRanking.utils.ts @@ -1,21 +1,46 @@ export interface KeyHolder { id: string; displayName: string; + /** + * Total keys attributed to this holder, counting both liquid keys sitting + * in the wallet and keys currently locked in the staking contract. + */ keyCount: number; + /** + * How many of `keyCount` are currently staked. Optional for callers that + * predate the staking-aware holders endpoint; treated as `0` when absent. + */ + stakedQuantity?: number; walletAddress?: string; } export interface RankedKeyHolder extends KeyHolder { rank: number; sharePercent: number; + /** Staked portion of `keyCount`, normalised to a non-negative integer-ish number. */ + stakedQuantity: number; + /** Liquid (non-staked) portion of `keyCount` — `keyCount - stakedQuantity`, floored at 0. */ + liquidQuantity: number; +} + +function normaliseStaked(holder: KeyHolder): number { + const staked = holder.stakedQuantity ?? 0; + if (!Number.isFinite(staked) || staked <= 0) return 0; + // A holder can never have more staked than they hold in total. + return Math.min(staked, holder.keyCount); } /** - * Ranks holders descending by key count and computes each holder's share of - * the total supply held across the list. + * Ranks holders descending by total quantity held (liquid + staked) and + * computes each holder's share of the total supply held across the list. * - * Ranks are competition-style ("1224"): holders tied on key count share the - * same rank, and the next distinct count resumes at its 1-indexed position + * Staked keys still count toward a holder's ranking — a holder who has staked + * their entire position is ranked exactly as if those keys were liquid. Each + * ranked entry also carries the `stakedQuantity` / `liquidQuantity` split so + * the holders table can surface true liquid supply per row. + * + * Ranks are competition-style ("1224"): holders tied on total quantity share + * the same rank, and the next distinct count resumes at its 1-indexed position * rather than incrementing by 1 — e.g. two holders tied for 1st both get * rank 1, and the following holder gets rank 3, not rank 2. */ @@ -32,10 +57,14 @@ export function rankKeyHolders(holders: KeyHolder[]): RankedKeyHolder[] { previousKeyCount = holder.keyCount; } + const stakedQuantity = normaliseStaked(holder); + return { ...holder, rank: currentRank, sharePercent: totalKeys > 0 ? (holder.keyCount / totalKeys) * 100 : 0, + stakedQuantity, + liquidQuantity: Math.max(0, holder.keyCount - stakedQuantity), }; }); } From aee87d425258d9e36451a57cf1ed195f4a2b4d73 Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:54:26 +0100 Subject: [PATCH 2/3] feat(key-detail): add Staking Rewards section (#817) Surface staking economics on the key detail page so holders can weigh staking: - Course gains stakingPoolBalance / totalStaked / recentFeeInflow from the key detail API - stakingRewards.utils computes estimated APY (recentFeeInflow * 12 / stakingPoolBalance * 100, null on divide-by-zero) and hasStakingActivity() for the hide condition - StakingRewardsSection renders pool balance, total staked and est. APY with a "Stake your keys" CTA to /profile?tab=staking, a loading skeleton while data fetches, and nothing when there is no staking activity - CreatorDetailPage renders it below the stat cards (demo stats until the API returns real values) Co-Authored-By: Claude Sonnet 5 --- .../common/StakingRewardsSection.tsx | 120 ++++++++++++++++++ .../__tests__/StakingRewardsSection.test.tsx | 77 +++++++++++ src/pages/CreatorDetailPage.tsx | 26 ++++ src/services/course.service.ts | 6 + .../__tests__/stakingRewards.utils.test.ts | 57 +++++++++ src/utils/stakingRewards.utils.ts | 46 +++++++ 6 files changed, 332 insertions(+) create mode 100644 src/components/common/StakingRewardsSection.tsx create mode 100644 src/components/common/__tests__/StakingRewardsSection.test.tsx create mode 100644 src/utils/__tests__/stakingRewards.utils.test.ts create mode 100644 src/utils/stakingRewards.utils.ts diff --git a/src/components/common/StakingRewardsSection.tsx b/src/components/common/StakingRewardsSection.tsx new file mode 100644 index 00000000..4c17cd27 --- /dev/null +++ b/src/components/common/StakingRewardsSection.tsx @@ -0,0 +1,120 @@ +import { Link } from 'react-router'; +import { Coins } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { formatNumber, formatPercent, formatXlmPrice } from '@/utils/numberFormat.utils'; +import { + computeEstimatedApy, + hasStakingActivity, + type StakingPoolStats, +} from '@/utils/stakingRewards.utils'; + +export interface StakingRewardsSectionProps extends StakingPoolStats { + /** Whether the key detail data is still loading. */ + isLoading?: boolean; + /** Where the "Stake your keys" CTA links to. */ + stakeHref?: string; +} + +const CARD_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +/** + * Staking Rewards panel for the key detail page (#817). + * + * Shows the current reward pool balance, total keys staked and an estimated + * APY derived from recent protocol fee inflows, plus a CTA into the portfolio + * staking tab. Renders a loading skeleton while the key detail data is in + * flight, and renders nothing once loaded if the key has no staking activity + * (nothing staked and an empty pool). + */ +const StakingRewardsSection: React.FC = ({ + stakingPoolBalance, + totalStaked, + recentFeeInflow, + isLoading = false, + stakeHref = '/profile?tab=staking', +}) => { + if (isLoading) { + return ( +
    + +
    + + + +
    + +
    + ); + } + + const stats: StakingPoolStats = { + stakingPoolBalance, + totalStaked, + recentFeeInflow, + }; + + if (!hasStakingActivity(stats)) { + return null; + } + + const apy = computeEstimatedApy(stats); + + const items = [ + { + label: 'Pool balance', + value: formatXlmPrice(stakingPoolBalance ?? 0), + testId: 'staking-pool-balance', + }, + { + label: 'Total staked', + value: `${formatNumber(totalStaked ?? 0)} keys`, + testId: 'staking-total-staked', + }, + { + label: 'Est. APY', + value: apy == null ? '—' : formatPercent(apy, { maximumFractionDigits: 1 }), + testId: 'staking-estimated-apy', + }, + ]; + + return ( +
    +
    +
    + +
    + {items.map(item => ( +
    +

    + {item.label} +

    +

    + {item.value} +

    +
    + ))} +
    + +

    + APY is an estimate: last month's protocol fee inflow, annualised over the + current pool balance. Actual rewards vary with trading volume. +

    + + +
    + ); +}; + +export default StakingRewardsSection; diff --git a/src/components/common/__tests__/StakingRewardsSection.test.tsx b/src/components/common/__tests__/StakingRewardsSection.test.tsx new file mode 100644 index 00000000..16badda4 --- /dev/null +++ b/src/components/common/__tests__/StakingRewardsSection.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import StakingRewardsSection from '@/components/common/StakingRewardsSection'; + +function renderSection(props: React.ComponentProps) { + return render( + + + + ); +} + +describe('StakingRewardsSection (#817)', () => { + it('renders pool balance and total staked', () => { + renderSection({ + stakingPoolBalance: 6000, + totalStaked: 42, + recentFeeInflow: 50, + }); + + expect(screen.getByTestId('staking-pool-balance')).toHaveTextContent('6000.00 XLM'); + expect(screen.getByTestId('staking-total-staked')).toHaveTextContent('42 keys'); + }); + + it('computes and displays the estimated APY', () => { + renderSection({ + stakingPoolBalance: 6000, + totalStaked: 42, + recentFeeInflow: 50, + }); + + // 50 * 12 / 6000 * 100 = 10% + expect(screen.getByTestId('staking-estimated-apy')).toHaveTextContent('10%'); + }); + + it('shows a placeholder APY when it cannot be computed', () => { + renderSection({ + stakingPoolBalance: 0, + totalStaked: 5, + recentFeeInflow: 50, + }); + + expect(screen.getByTestId('staking-estimated-apy')).toHaveTextContent('—'); + }); + + it('links the CTA to the portfolio staking tab', () => { + renderSection({ + stakingPoolBalance: 6000, + totalStaked: 42, + recentFeeInflow: 50, + }); + + const cta = screen.getByTestId('staking-cta'); + expect(cta.tagName).toBe('A'); + expect(cta).toHaveAttribute('href', '/profile?tab=staking'); + expect(cta).toHaveTextContent('Stake your keys'); + }); + + it('renders nothing when there is no staking activity', () => { + const { container } = renderSection({ + stakingPoolBalance: 0, + totalStaked: 0, + recentFeeInflow: 0, + }); + + expect(screen.queryByTestId('staking-rewards-section')).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows a loading skeleton while data fetches', () => { + renderSection({ isLoading: true }); + + expect(screen.getByTestId('staking-rewards-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('staking-rewards-section')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index dd053224..c06d638e 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -9,6 +9,7 @@ import CreatorProfileStaleIndicator from '@/components/common/CreatorProfileStal import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; import { BondingCurveChart } from '@/components/common/BondingCurveChart'; import KeyHolderList from '@/components/common/KeyHolderList'; +import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; import { bpsToPercent, formatNumber } from '@/utils/numberFormat.utils'; import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; @@ -108,6 +109,25 @@ function CreatorDetailPageContent() { { id: 'h5', displayName: 'Key Holder 5', keyCount: 5, stakedQuantity: 2 }, ]; + const hasRealStakingData = + creator.stakingPoolBalance != null || + creator.totalStaked != null || + creator.recentFeeInflow != null; + const stakingStats = hasRealStakingData + ? { + stakingPoolBalance: creator.stakingPoolBalance, + totalStaked: creator.totalStaked, + recentFeeInflow: creator.recentFeeInflow, + } + : { + // Demo values until the key detail API returns staking pool stats. + stakingPoolBalance: 4820, + totalStaked: creator.creatorShareSupply + ? Math.floor(creator.creatorShareSupply / 4) + : 25, + recentFeeInflow: 62, + }; + return (
    @@ -132,6 +152,12 @@ function CreatorDetailPageContent() {
    + {/* Staking Rewards */} + + {/* Price Chart */}
    { + it('annualises the recent fee inflow over the pool balance as a percentage', () => { + // 50 * 12 / 6000 * 100 = 10 + expect( + computeEstimatedApy({ recentFeeInflow: 50, stakingPoolBalance: 6000 }) + ).toBeCloseTo(10, 10); + }); + + it('returns null when the pool balance is zero', () => { + expect( + computeEstimatedApy({ recentFeeInflow: 50, stakingPoolBalance: 0 }) + ).toBeNull(); + }); + + it('returns null when the pool balance is missing', () => { + expect(computeEstimatedApy({ recentFeeInflow: 50 })).toBeNull(); + }); + + it('treats a missing or negative fee inflow as zero APY', () => { + expect( + computeEstimatedApy({ stakingPoolBalance: 6000 }) + ).toBe(0); + expect( + computeEstimatedApy({ recentFeeInflow: -20, stakingPoolBalance: 6000 }) + ).toBe(0); + }); + + it('never returns Infinity or NaN for non-finite inputs', () => { + expect( + computeEstimatedApy({ + recentFeeInflow: Number.POSITIVE_INFINITY, + stakingPoolBalance: Number.NaN, + }) + ).toBeNull(); + }); +}); + +describe('hasStakingActivity (#817)', () => { + it('is true when keys are staked', () => { + expect(hasStakingActivity({ totalStaked: 3, stakingPoolBalance: 0 })).toBe(true); + }); + + it('is true when the pool holds a balance even with nothing staked', () => { + expect(hasStakingActivity({ totalStaked: 0, stakingPoolBalance: 12 })).toBe(true); + }); + + it('is false when nothing is staked and the pool is empty', () => { + expect(hasStakingActivity({ totalStaked: 0, stakingPoolBalance: 0 })).toBe(false); + expect(hasStakingActivity({})).toBe(false); + }); +}); diff --git a/src/utils/stakingRewards.utils.ts b/src/utils/stakingRewards.utils.ts new file mode 100644 index 00000000..2ee21f5f --- /dev/null +++ b/src/utils/stakingRewards.utils.ts @@ -0,0 +1,46 @@ +/** + * Staking-rewards math for the key detail page (#817). + * + * The key detail API returns three fields backing the Staking Rewards panel: + * - `stakingPoolBalance` — XLM currently sitting in the staking reward pool + * - `totalStaked` — number of keys staked across all holders + * - `recentFeeInflow` — protocol fees that flowed into the pool over the most + * recent period (one month) + */ + +export interface StakingPoolStats { + stakingPoolBalance?: number | null; + totalStaked?: number | null; + recentFeeInflow?: number | null; +} + +function toNonNegative(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value) || value <= 0) return 0; + return value; +} + +/** + * Estimated APY as `recentFeeInflow * 12 / stakingPoolBalance * 100`, i.e. the + * most recent month's fee inflow annualised and expressed as a percentage of + * the current pool balance. + * + * Returns `null` when it cannot be computed (no pool balance, or missing / + * non-finite inputs) so callers can render a placeholder rather than + * `Infinity` / `NaN`. + */ +export function computeEstimatedApy(stats: StakingPoolStats): number | null { + const poolBalance = toNonNegative(stats.stakingPoolBalance); + const feeInflow = toNonNegative(stats.recentFeeInflow); + + if (poolBalance === 0) return null; + + return (feeInflow * 12) / poolBalance * 100; +} + +/** + * Whether the key has any staking activity worth showing a panel for. The + * section is hidden when nothing is staked and the reward pool is empty. + */ +export function hasStakingActivity(stats: StakingPoolStats): boolean { + return toNonNegative(stats.totalStaked) > 0 || toNonNegative(stats.stakingPoolBalance) > 0; +} From 8d52c059a142268541f606bd9500da6db6dcd878 Mon Sep 17 00:00:00 2001 From: devsimze <238806700+devsimze@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:09:02 +0100 Subject: [PATCH 3/3] feat(dashboard): add creator dashboard settings tab with profile + auction (#818, #816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a creator dashboard at /creator/:id/dashboard whose Settings tab hosts the two creator-facing configuration surfaces. Both issues share this previously-missing page. #818 — Edit Profile: - creatorMetadata.utils: name (64) / bio (256) limit validation and a minimal-change diff - CreatorMetadataForm pre-fills current metadata, shows live character counters, blocks submit while over a limit or unchanged, and submits only changed fields - useUpdateMetadataMutation issues the update_metadata contract call #816 — Auction Setup: - auctionConfig.utils: derives Not configured / Active (X of Y sold) / Completed state and validates price (> 0) and supply (whole, > 0) - AuctionSetupPanel shows current state, validated price/supply inputs, and a Cancel Auction button only while auction_sold is zero - useConfigureAuctionMutation / useCancelAuctionMutation issue configure_auction / cancel_auction with a success toast on confirmation Course gains name/bio/avatarUri and auctionPrice/auctionSupply/auctionSold. Co-Authored-By: Claude Sonnet 5 --- src/components/common/AuctionSetupPanel.tsx | 166 ++++++++++++++++ src/components/common/CreatorMetadataForm.tsx | 183 ++++++++++++++++++ .../__tests__/AuctionSetupPanel.test.tsx | 93 +++++++++ .../__tests__/CreatorMetadataForm.test.tsx | 90 +++++++++ src/hooks/useCreatorContractActions.ts | 86 ++++++++ src/pages/CreatorDashboardPage.tsx | 170 ++++++++++++++++ src/routes.tsx | 9 + src/services/course.service.ts | 10 + .../__tests__/auctionConfig.utils.test.ts | 73 +++++++ .../__tests__/creatorMetadata.utils.test.ts | 78 ++++++++ src/utils/auctionConfig.utils.ts | 84 ++++++++ src/utils/creatorMetadata.utils.ts | 63 ++++++ 12 files changed, 1105 insertions(+) create mode 100644 src/components/common/AuctionSetupPanel.tsx create mode 100644 src/components/common/CreatorMetadataForm.tsx create mode 100644 src/components/common/__tests__/AuctionSetupPanel.test.tsx create mode 100644 src/components/common/__tests__/CreatorMetadataForm.test.tsx create mode 100644 src/hooks/useCreatorContractActions.ts create mode 100644 src/pages/CreatorDashboardPage.tsx create mode 100644 src/utils/__tests__/auctionConfig.utils.test.ts create mode 100644 src/utils/__tests__/creatorMetadata.utils.test.ts create mode 100644 src/utils/auctionConfig.utils.ts create mode 100644 src/utils/creatorMetadata.utils.ts diff --git a/src/components/common/AuctionSetupPanel.tsx b/src/components/common/AuctionSetupPanel.tsx new file mode 100644 index 00000000..4e72107f --- /dev/null +++ b/src/components/common/AuctionSetupPanel.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + canCancelAuction, + describeAuctionState, + getAuctionStatus, + validateAuctionInputs, + type AuctionConfig, +} from '@/utils/auctionConfig.utils'; + +export interface AuctionSetupPanelProps extends AuctionConfig { + /** Submits `configure_auction`. */ + onConfigure: (config: { price: number; supply: number }) => void; + /** Submits `cancel_auction`. */ + onCancel: () => void; + isSubmitting?: boolean; +} + +const fieldClass = + 'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50'; + +/** + * Auction configuration panel for the creator dashboard settings tab (#816). + * + * Lets a creator set a fixed auction price (XLM) and supply allocation before + * their key goes live, shows the current auction state, and offers a Cancel + * Auction action while no auction keys have been sold. + */ +const AuctionSetupPanel: React.FC = ({ + auctionPrice, + auctionSupply, + auctionSold, + onConfigure, + onCancel, + isSubmitting = false, +}) => { + const config: AuctionConfig = { auctionPrice, auctionSupply, auctionSold }; + const status = getAuctionStatus(config); + + const [priceInput, setPriceInput] = useState( + auctionPrice != null ? String(auctionPrice) : '' + ); + const [supplyInput, setSupplyInput] = useState( + auctionSupply != null ? String(auctionSupply) : '' + ); + const [showErrors, setShowErrors] = useState(false); + + // Keep inputs aligned with upstream config after a save refetches it. + useEffect(() => { + setPriceInput(auctionPrice != null ? String(auctionPrice) : ''); + setSupplyInput(auctionSupply != null ? String(auctionSupply) : ''); + setShowErrors(false); + }, [auctionPrice, auctionSupply]); + + const { priceError, supplyError, isValid } = validateAuctionInputs( + priceInput, + supplyInput + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + if (!isValid) { + setShowErrors(true); + return; + } + onConfigure({ price: Number(priceInput.trim()), supply: Number(supplyInput.trim()) }); + }; + + return ( +
    +
    +

    + Current state +

    +

    + {describeAuctionState(config)} +

    +
    + +
    +
    +
    + + setPriceInput(e.target.value)} + disabled={isSubmitting} + placeholder="0.00" + aria-invalid={showErrors && priceError ? 'true' : undefined} + /> + {showErrors && priceError && ( +

    + {priceError} +

    + )} +
    + +
    + + setSupplyInput(e.target.value)} + disabled={isSubmitting} + placeholder="0" + aria-invalid={showErrors && supplyError ? 'true' : undefined} + /> + {showErrors && supplyError && ( +

    + {supplyError} +

    + )} +
    +
    + +
    + + + {canCancelAuction(config) && ( + + )} +
    +
    +
    + ); +}; + +export default AuctionSetupPanel; diff --git a/src/components/common/CreatorMetadataForm.tsx b/src/components/common/CreatorMetadataForm.tsx new file mode 100644 index 00000000..d8cb485c --- /dev/null +++ b/src/components/common/CreatorMetadataForm.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { + CREATOR_BIO_MAX_LENGTH, + CREATOR_NAME_MAX_LENGTH, + diffCreatorMetadata, + validateCreatorMetadata, + type CreatorMetadataChange, + type CreatorMetadataDraft, +} from '@/utils/creatorMetadata.utils'; + +export interface CreatorMetadataFormProps { + initialName: string; + initialBio: string; + initialAvatarUri: string; + /** Receives only the fields that changed. Never called with an empty change. */ + onSubmit: (change: CreatorMetadataChange) => void; + isSubmitting?: boolean; +} + +const fieldClass = + 'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50'; + +function CharacterCounter({ + length, + max, + testId, +}: { + length: number; + max: number; + testId: string; +}) { + const over = length > max; + return ( + max - 20 ? 'text-amber-400' : 'text-white/40' + )} + > + {length} / {max} + + ); +} + +/** + * Edit Profile form for the creator dashboard settings tab (#818). + * + * Pre-fills from the current metadata, shows live character counters, blocks + * submission when the name (64) or bio (256) limits are exceeded, and submits + * only the fields that actually changed via `onSubmit`. + */ +const CreatorMetadataForm: React.FC = ({ + initialName, + initialBio, + initialAvatarUri, + onSubmit, + isSubmitting = false, +}) => { + const initial = useMemo( + () => ({ name: initialName, bio: initialBio, avatarUri: initialAvatarUri }), + [initialName, initialBio, initialAvatarUri] + ); + + const [draft, setDraft] = useState(initial); + + // Re-sync when the upstream metadata changes (e.g. after a successful save + // invalidates and refetches the creator detail query). + useEffect(() => { + setDraft(initial); + }, [initial]); + + const { nameError, bioError, isValid } = validateCreatorMetadata(draft); + const change = diffCreatorMetadata(initial, draft); + const hasChange = Object.keys(change).length > 0; + const canSubmit = isValid && hasChange && !isSubmitting; + + const update = (field: keyof CreatorMetadataDraft, value: string) => { + setDraft(prev => ({ ...prev, [field]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + onSubmit(change); + }; + + return ( +
    +
    +
    + + +
    + update('name', e.target.value)} + disabled={isSubmitting} + aria-invalid={nameError ? 'true' : undefined} + aria-describedby={nameError ? 'metadata-name-error' : undefined} + /> + {nameError && ( + + )} +
    + +
    +
    + + +
    +