diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 5c83b95..4e70d94 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -47,7 +47,15 @@ import { CREATOR_CARD_ENTRY_CLASS, creatorCardEntryStyle, } from '@/utils/cardEntryAnimation.utils'; -import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, +} from '@/utils/keyPriceDisplay.utils'; +import { + calculatePortfolioValue, + formatPortfolioValueDisplay, + getPortfolioValueHelperText, +} from '@/utils/portfolioValue.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'; @@ -181,6 +189,7 @@ const MAX_CREATOR_FETCH_RETRIES = 3; const BASE_RETRY_DELAY_MS = 800; const PAGE_SIZE = 6; const FETCH_RETRY_ACTION_LABEL = 'Try again'; +const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; @@ -234,7 +243,9 @@ function LandingPage() { // Last successful fetch timestamp (#301). `null` means we've never // resolved a load yet — the staleness helper treats that as "stale" // so the warning surfaces if the load hangs. - const [creatorsFetchedAt, setCreatorsFetchedAt] = useState(null); + const [creatorsFetchedAt, setCreatorsFetchedAt] = useState( + null + ); const { isMismatch: isNetworkMismatch } = useNetworkMismatch(); const [isLoading, setIsLoading] = useState(true); const [isFilterLoading, setIsFilterLoading] = useState(false); @@ -495,6 +506,39 @@ function LandingPage() { } ); + const heldKeyPositions = useMemo( + () => + creators.map((creator, index) => ({ + creatorId: creator.id, + quantity: + index === 0 + ? featuredHoldings + : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0), + priceStroops: creator.priceStroops, + price: creator.price, + isPriceLoading: isPriceRefreshing, + isPriceStale: creatorsAreStale, + })), + [creators, creatorsAreStale, featuredHoldings, isPriceRefreshing] + ); + const portfolioValue = useMemo( + () => calculatePortfolioValue(heldKeyPositions), + [heldKeyPositions] + ); + const displayedPortfolioValue = isLoading + ? { + ...portfolioValue, + status: 'loading' as const, + totalStroops: null, + } + : portfolioValue; + const portfolioValueDisplay = formatPortfolioValueDisplay( + displayedPortfolioValue + ); + const portfolioValueHelperText = getPortfolioValueHelperText( + displayedPortfolioValue + ); + const openTradeDialog = (side: TradeSide) => { setTradeSide(side); setTradeDialogOpen(true); @@ -538,7 +582,10 @@ function LandingPage() { return (
- + {/* #306: the outer wrapper is just a decorative shell; the actual landmark structure is a top-level
sibling of the
below, so screen-reader landmark navigation lands directly on the @@ -616,8 +663,12 @@ function LandingPage() { > - - + +
@@ -646,7 +697,11 @@ function LandingPage() {
{pagedCreators.map(creator => ( - + ))}
@@ -716,11 +771,16 @@ function LandingPage() { @@ -764,7 +824,94 @@ function LandingPage() { - + + +
+
+

+ Your holdings +

+

+ Total portfolio value +

+

+ Aggregates every creator key position currently held + by this wallet using the latest available key prices. +

+
+
+
+ Portfolio total +
+
+ {displayedPortfolioValue.status === 'loading' && ( +
+

+ {portfolioValueHelperText} +

+
+
+
+ {heldKeyPositions + .filter( + position => + position.quantity && position.quantity > 0 + ) + .map(position => { + const creator = creators.find( + item => item.id === position.creatorId + ); + return ( +
+
+ {creator?.title ?? 'Unknown creator'} +
+
+ {formatNumber(position.quantity)} keys ·{' '} + {position.isPriceLoading + ? 'Refreshing price' + : position.isPriceStale + ? 'Price stale' + : formatDisplayKeyPrice( + resolveCreatorKeyPriceStroops( + position + ) + )} +
+
+ ); + })} +
+
+ +
- + {finalFetchError ? ( - Use the same subtitle pattern beneath headings, then - drop repeated creator facts into one responsive grid - that stays tidy on mobile and desktop. + Use the same subtitle pattern beneath headings, + then drop repeated creator facts into one + responsive grid that stays tidy on mobile and + desktop.
@@ -903,11 +1062,11 @@ function LandingPage() { value={ precisionMode === 'compact' ? `${formatCompactNumber( - featuredCreator?.creatorShareSupply - )} shares available` + featuredCreator?.creatorShareSupply + )} shares available` : `${formatNumber( - featuredCreator?.creatorShareSupply - )} shares available` + featuredCreator?.creatorShareSupply + )} shares available` } /> {isNetworkMismatch && } @@ -915,14 +1074,17 @@ function LandingPage() {
@@ -930,7 +1092,9 @@ function LandingPage() { className="rounded-xl" variant="outline" onClick={() => openTradeDialog('sell')} - disabled={isNetworkMismatch || tradeSubmitting} + disabled={ + isNetworkMismatch || tradeSubmitting + } > Sell @@ -959,14 +1123,27 @@ function LandingPage() { className="truncate font-jakarta text-sm font-bold text-white/85" aria-label={`Wallet holdings: ${formatNumber( featuredHoldings - )} keys${formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply) !== '—' - ? ` (${formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply)})` - : ''}`} + )} keys${ + formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply + ) !== '—' + ? ` (${formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply)})` + : '' + }`} > {formatNumber(featuredHoldings)} keys - {formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply) !== '—' && ( + {formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply + ) !== '—' && ( - ({formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply)}) + ( + {formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply + )} + ) )}
@@ -976,7 +1153,8 @@ function LandingPage() {
diff --git a/src/utils/__tests__/portfolioValue.utils.test.ts b/src/utils/__tests__/portfolioValue.utils.test.ts new file mode 100644 index 0000000..e2589b1 --- /dev/null +++ b/src/utils/__tests__/portfolioValue.utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + calculatePortfolioValue, + formatPortfolioValueDisplay, + getPortfolioValueHelperText, +} from '../portfolioValue.utils'; + +describe('calculatePortfolioValue', () => { + it('sums each held key quantity against its current price', () => { + const result = calculatePortfolioValue([ + { creatorId: 'alex', quantity: 3, priceStroops: 500_000 }, + { creatorId: 'sarah', quantity: 2, priceStroops: 1_200_000 }, + ]); + + expect(result).toMatchObject({ + status: 'ready', + totalStroops: 3_900_000, + heldPositionCount: 2, + }); + expect(formatPortfolioValueDisplay(result)).toBe('0.39 XLM'); + expect(getPortfolioValueHelperText(result)).toBe( + 'Across 2 held creator positions.' + ); + }); + + it('returns a zero total for zero holdings without requiring price data', () => { + const result = calculatePortfolioValue([ + { creatorId: 'alex', quantity: 0, priceStroops: null }, + { creatorId: 'sarah', quantity: -1, priceStroops: 1_200_000 }, + ]); + + expect(result).toMatchObject({ + status: 'ready', + totalStroops: 0, + heldPositionCount: 0, + }); + expect(formatPortfolioValueDisplay(result)).toBe('0 XLM'); + expect(getPortfolioValueHelperText(result)).toBe( + 'No held creator keys yet.' + ); + }); + + it('shows loading instead of a partial total while prices refresh', () => { + const result = calculatePortfolioValue([ + { creatorId: 'alex', quantity: 3, priceStroops: 500_000 }, + { + creatorId: 'sarah', + quantity: 2, + priceStroops: 1_200_000, + isPriceLoading: true, + }, + ]); + + expect(result).toMatchObject({ + status: 'loading', + totalStroops: null, + heldPositionCount: 2, + }); + expect(formatPortfolioValueDisplay(result)).toBe('Loading prices…'); + }); + + it('marks totals unavailable when a held position is missing price data', () => { + const result = calculatePortfolioValue([ + { creatorId: 'alex', quantity: 3, priceStroops: 500_000 }, + { creatorId: 'marcus', quantity: 1, priceStroops: null, price: null }, + ]); + + expect(result).toMatchObject({ + status: 'unavailable', + totalStroops: null, + missingPriceCount: 1, + }); + expect(formatPortfolioValueDisplay(result)).toBe('Unavailable'); + expect(getPortfolioValueHelperText(result)).toBe( + 'One or more held positions is missing current price data.' + ); + }); + + it('marks totals unavailable when a held position has stale price data', () => { + const result = calculatePortfolioValue([ + { + creatorId: 'alex', + quantity: 3, + priceStroops: 500_000, + isPriceStale: true, + }, + ]); + + expect(result).toMatchObject({ + status: 'unavailable', + totalStroops: null, + stalePriceCount: 1, + }); + expect(getPortfolioValueHelperText(result)).toBe( + 'One or more held positions has stale price data. Refresh prices to show the total.' + ); + }); +}); diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts new file mode 100644 index 0000000..07f1d9f --- /dev/null +++ b/src/utils/portfolioValue.utils.ts @@ -0,0 +1,130 @@ +import { + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, + type CreatorKeyPriceFields, +} from '@/utils/keyPriceDisplay.utils'; + +export interface HeldKeyPosition extends CreatorKeyPriceFields { + creatorId: string; + quantity: number | null | undefined; + isPriceLoading?: boolean; + isPriceStale?: boolean; +} + +export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable'; + +export interface PortfolioValueResult { + status: PortfolioValueStatus; + totalStroops: number | null; + heldPositionCount: number; + missingPriceCount: number; + stalePriceCount: number; +} + +const normalizeHeldQuantity = (quantity: number | null | undefined) => { + if (quantity == null || !Number.isFinite(quantity) || quantity <= 0) { + return 0; + } + + return quantity; +}; + +/** + * Aggregates the current portfolio value across all held creator-key positions. + * + * The helper intentionally withholds a partial total when any held position has + * loading, missing, or stale price data so the UI never presents an incorrect + * portfolio value as complete. + */ +export function calculatePortfolioValue( + positions: HeldKeyPosition[] +): PortfolioValueResult { + const heldPositions = positions.filter( + position => normalizeHeldQuantity(position.quantity) > 0 + ); + + if (heldPositions.length === 0) { + return { + status: 'ready', + totalStroops: 0, + heldPositionCount: 0, + missingPriceCount: 0, + stalePriceCount: 0, + }; + } + + const missingPriceCount = heldPositions.filter( + position => resolveCreatorKeyPriceStroops(position) == null + ).length; + const stalePriceCount = heldPositions.filter( + position => position.isPriceStale + ).length; + + if (heldPositions.some(position => position.isPriceLoading)) { + return { + status: 'loading', + totalStroops: null, + heldPositionCount: heldPositions.length, + missingPriceCount, + stalePriceCount, + }; + } + + if (missingPriceCount > 0 || stalePriceCount > 0) { + return { + status: 'unavailable', + totalStroops: null, + heldPositionCount: heldPositions.length, + missingPriceCount, + stalePriceCount, + }; + } + + const totalStroops = heldPositions.reduce((total, position) => { + const priceStroops = resolveCreatorKeyPriceStroops(position); + + return ( + total + (priceStroops ?? 0) * normalizeHeldQuantity(position.quantity) + ); + }, 0); + + return { + status: 'ready', + totalStroops, + heldPositionCount: heldPositions.length, + missingPriceCount: 0, + stalePriceCount: 0, + }; +} + +export function formatPortfolioValueDisplay(result: PortfolioValueResult) { + if (result.status === 'loading') { + return 'Loading prices…'; + } + + if (result.status === 'unavailable') { + return 'Unavailable'; + } + + return formatDisplayKeyPrice(result.totalStroops); +} + +export function getPortfolioValueHelperText(result: PortfolioValueResult) { + if (result.status === 'loading') { + return 'Refreshing key prices before calculating your total.'; + } + + if (result.status === 'unavailable') { + if (result.stalePriceCount > 0) { + return 'One or more held positions has stale price data. Refresh prices to show the total.'; + } + + return 'One or more held positions is missing current price data.'; + } + + if (result.heldPositionCount === 0) { + return 'No held creator keys yet.'; + } + + return `Across ${result.heldPositionCount} held creator ${result.heldPositionCount === 1 ? 'position' : 'positions'}.`; +}