From a5982d22d98b78750423e5f33615f8d245887b45 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 30 May 2026 23:12:00 +0100 Subject: [PATCH] feat(client): implement reactive key ownership percentage display with safe zero supply boundaries --- src/pages/LandingPage.tsx | 732 ++++++++++---------- src/utils/__tests__/ownership.utils.test.ts | 64 ++ src/utils/ownership.utils.ts | 36 + 3 files changed, 484 insertions(+), 348 deletions(-) create mode 100644 src/utils/__tests__/ownership.utils.test.ts create mode 100644 src/utils/ownership.utils.ts diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 67c9d8ca..f33ec486 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -33,6 +33,7 @@ import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; +import { formatOwnershipPercent } from '@/utils/ownership.utils'; import PrecisionModeToggle, { type PrecisionMode, } from '@/components/common/PrecisionModeToggle'; @@ -453,6 +454,11 @@ function LandingPage() { FEATURED_CREATOR_KEY_HOLDER_COUNT ); + // Choose the featured creator from live data when available, otherwise + // fall back to the demo featured creator. This keeps the profile panel + // reactive to backend updates (supply, price, etc.). + const featuredCreator = creators.length > 0 ? creators[0] : DEMO_CREATORS[0]; + useEffect(() => { if (pendingScrollRestoreRef.current == null) return; const target = pendingScrollRestoreRef.current; @@ -576,322 +582,405 @@ function LandingPage() { 0} - > -
- -
- - + eyebrow="Marketplace filters" + title="Find creators without losing your place" + description="Search by creator name or handle while you keep scrolling through the marketplace. The filter shell stays visible and compact so you can refine results without losing your place." + resultCount={filteredCreators.length} + onReset={handleResetSearch} + showReset={searchQuery.length > 0} + > +
+ +
+ + +
-
- + - + - - - + + + - {isLoading ? ( - - ) : isFilterLoading ? ( -
-
-
- - Updating results... - -
-
- {pagedCreators.map(creator => ( - - ))} -
-
- ) : filteredCreators.length > 0 ? ( -
- {showRetryBanner && ( - - )} - {finalFetchError && ( -
- {finalFetchError} + {isLoading ? ( + + ) : isFilterLoading ? ( +
+
+
+ + Updating results... + +
+
+ {pagedCreators.map(creator => ( + + ))}
- )} - {/* #301: subtle inline stale-data warning that +
+ ) : filteredCreators.length > 0 ? ( +
+ {showRetryBanner && ( + + )} + {finalFetchError && ( +
+ {finalFetchError} +
+ )} + {/* #301: subtle inline stale-data warning that appears once the cached creator data is past the 60s freshness window. The hook drives a background refresh that resets the baseline and clears the warning automatically. */} - {creatorsAreStale && ( - + )} + +
+ {pagedCreators.map((creator, index) => ( + // #300: staggered entry animation; the + // helper no-ops on prefers-reduced-motion. + // #355: layout transition when sort order changes. + + + + ))} +
+
+ - )} - -
- {pagedCreators.map((creator, index) => ( - // #300: staggered entry animation; the - // helper no-ops on prefers-reduced-motion. - // #355: layout transition when sort order changes. - +
-
- - {safePage < totalPages - 1 && ( -
- -
- )} +
+ )} {safePage >= totalPages - 1 && (

- {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`} -

- )} -
- ) : ( -
- {trimmedSearchQuery.length === 0 ? ( - - ) : ( - <> - + {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`} +

+ )} +
+ ) : ( +
+ {trimmedSearchQuery.length === 0 ? ( + - {!hasInvalidSearchInput && ( - + - )} - - )} -
- )} - - - - - -
- - - {isLoading ? ( - - ) : ( - - )} + {!hasInvalidSearchInput && ( + + )} + + )} +
+ )} + -
- - {finalFetchError ? ( - + +
+ - ) : ( - -
- - + ) : ( + - - Use the same subtitle pattern beneath headings, then - drop repeated creator facts into one responsive grid - that stays tidy on mobile and desktop. - -
-
- - - + )} + +
+ + + {finalFetchError ? ( + + ) : ( + +
+ + + + Use the same subtitle pattern beneath headings, then + drop repeated creator facts into one responsive grid + that stays tidy on mobile and desktop. + +
+
+ + + +
-
-
- + -
- - Metrics display - - +
+ + Metrics display + + +
+ + {isNetworkMismatch && } +
+
+ + +
+ {tradeSubmitting && ( +
+
+
+ Submitting trade +
+
+ )} +
- - {isNetworkMismatch && } + + )} + + +
+
+
+
+ Your holdings +
+
+ {formatNumber(featuredHoldings)} keys + {formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply) !== '—' && ( + + ({formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply)}) + + )} +
+
+
{tradeSubmitting && ( -
-
-
+
+
+
Submitting trade
)}
- - )} - - -
-
-
-
- Your holdings -
-
- {formatNumber(featuredHoldings)} keys -
-
-
-
-
- - -
- {tradeSubmitting && ( -
-
-
- Submitting trade -
-
- )} -
-
- - - - - + + + + +
@@ -989,7 +1025,7 @@ function LandingPage() { side={tradeSide} creatorName="Alex Rivers" availableHoldings={featuredHoldings} - keyPriceStroops={resolveCreatorKeyPriceStroops(DEMO_CREATORS[0])} + keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} diff --git a/src/utils/__tests__/ownership.utils.test.ts b/src/utils/__tests__/ownership.utils.test.ts new file mode 100644 index 00000000..4e016780 --- /dev/null +++ b/src/utils/__tests__/ownership.utils.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { + computeOwnershipPercentage, + formatOwnershipPercent, +} from '../ownership.utils'; + +describe('ownership.utils', () => { + describe('computeOwnershipPercentage', () => { + it('returns null when balance is null', () => { + expect(computeOwnershipPercentage(null, 100)).toBeNull(); + }); + + it('returns null when totalSupply is null', () => { + expect(computeOwnershipPercentage(10, null)).toBeNull(); + }); + + it('returns null when totalSupply is zero or negative', () => { + expect(computeOwnershipPercentage(10, 0)).toBeNull(); + expect(computeOwnershipPercentage(10, -5)).toBeNull(); + }); + + it('computes correct percentage for normal values', () => { + expect(computeOwnershipPercentage(25, 100)).toBeCloseTo(25); + expect(computeOwnershipPercentage(1, 3)).toBeCloseTo((1 / 3) * 100); + }); + + it('handles balances greater than supply ( >100%)', () => { + expect(computeOwnershipPercentage(150, 100)).toBeCloseTo(150); + }); + + it('returns null for non-finite inputs', () => { + expect(computeOwnershipPercentage(NaN, 100)).toBeNull(); + expect(computeOwnershipPercentage(10, Infinity)).toBeNull(); + }); + }); + + describe('formatOwnershipPercent', () => { + it('returns placeholder when percentage cannot be computed', () => { + expect(formatOwnershipPercent(null, 100)).toBe('—'); + expect(formatOwnershipPercent(10, 0)).toBe('—'); + }); + + it('formats percentage with default precision', () => { + expect(formatOwnershipPercent(1, 4)).toBe('25%'); + expect(formatOwnershipPercent(1, 3)).toBe('33.33%'); + }); + + it('respects maximumFractionDigits option', () => { + expect(formatOwnershipPercent(1, 3, { maximumFractionDigits: 1 })).toBe( + '33.3%' + ); + }); + + it('handles large balances and supplies', () => { + expect(formatOwnershipPercent(1_000_000, 10_000_000)).toBe('10%'); + }); + + it('can show signed positives when requested', () => { + expect( + formatOwnershipPercent(1, 4, { signed: true, maximumFractionDigits: 0 }) + ).toBe('+25%'); + }); + }); +}); diff --git a/src/utils/ownership.utils.ts b/src/utils/ownership.utils.ts new file mode 100644 index 00000000..3d2ebe39 --- /dev/null +++ b/src/utils/ownership.utils.ts @@ -0,0 +1,36 @@ +import { formatPercent } from '@/utils/numberFormat.utils'; + +export interface FormatOwnershipOptions { + maximumFractionDigits?: number; + minimumFractionDigits?: number; + signed?: boolean; +} + +export function computeOwnershipPercentage( + balance: number | null | undefined, + totalSupply: number | null | undefined +): number | null { + if (balance == null || totalSupply == null) return null; + if (!Number.isFinite(balance) || !Number.isFinite(totalSupply)) return null; + if (totalSupply <= 0) return null; + return (balance / totalSupply) * 100; +} + +export function formatOwnershipPercent( + balance: number | null | undefined, + totalSupply: number | null | undefined, + options: FormatOwnershipOptions = {} +): string { + const pct = computeOwnershipPercentage(balance, totalSupply); + if (pct == null) return '—'; + return formatPercent(pct, { + maximumFractionDigits: options.maximumFractionDigits ?? 2, + minimumFractionDigits: options.minimumFractionDigits ?? 0, + signed: options.signed ?? false, + }); +} + +export default { + computeOwnershipPercentage, + formatOwnershipPercent, +};