From ddb817ed41fdf6508e3a79c9f262d2f107b6d68b Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Tue, 2 Jun 2026 02:23:02 +0100 Subject: [PATCH 1/2] fix(wallet): add loading state during network balance refresh --- src/App.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App.css b/src/App.css index 2e37e5c..2c76133 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,4 @@ button { cursor: pointer; +padding: 30; } From aac19e49a0f5071449229086b4557e7af9e73dce Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Tue, 2 Jun 2026 02:25:44 +0100 Subject: [PATCH 2/2] fix(wallet): add loading state during network balance refresh --- src/components/common/TradeDialog.tsx | 46 ++++++- .../__tests__/TradeDialog.focusOrder.test.tsx | 8 ++ .../__tests__/useNetworkAwareBalance.test.ts | 115 ++++++++++++++++ src/hooks/useNetworkAwareBalance.ts | 123 ++++++++++++++++++ src/pages/LandingPage.tsx | 94 +++++++++++-- 5 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 src/hooks/__tests__/useNetworkAwareBalance.test.ts create mode 100644 src/hooks/useNetworkAwareBalance.ts diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 728850b..5d19ca3 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -23,7 +23,8 @@ export interface TradeDialogProps { open: boolean; side: TradeSide; creatorName: string; - availableHoldings: number; + availableHoldings: number | null; + isBalanceLoading?: boolean; /** Per-key price in stroops, shown on the buy confirmation step. */ keyPriceStroops?: number | null; onOpenChange: (open: boolean) => void; @@ -36,6 +37,7 @@ const TradeDialog: React.FC = ({ side, creatorName, availableHoldings, + isBalanceLoading = false, keyPriceStroops, onOpenChange, onConfirm, @@ -58,6 +60,14 @@ const TradeDialog: React.FC = ({ return Number(normalized); }, [amountText]); +<<<<<<< HEAD + const amountValid = + Number.isFinite(parsedAmount) && + parsedAmount > 0 && + !isBalanceLoading && + (side !== 'sell' || + (availableHoldings != null && parsedAmount <= availableHoldings)); +======= const validationError = useMemo((): string | null => { const normalized = amountText.trim(); if (!normalized) return 'Please enter an amount.'; @@ -70,6 +80,7 @@ const TradeDialog: React.FC = ({ const amountValid = validationError === null; const showError = touched && validationError !== null; +>>>>>>> upstream/main const title = side === 'buy' ? 'Buy keys' : 'Sell keys'; const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell'; @@ -151,11 +162,23 @@ const TradeDialog: React.FC = ({ )}
- Holdings: {formatNumber(availableHoldings)} keys + {isBalanceLoading || availableHoldings == null + ? 'Holdings: Loading...' + : `Holdings: ${formatNumber(availableHoldings)} keys`} {side === 'sell' && + !isBalanceLoading && + availableHoldings != null && availableHoldings > 0 && Number.isFinite(parsedAmount) && parsedAmount > 0 && ( @@ -170,6 +193,22 @@ const TradeDialog: React.FC = ({ /> )}
+<<<<<<< HEAD + + {side === 'sell' && + !isBalanceLoading && + availableHoldings != null && + parsedAmount > availableHoldings && ( +
+ You can’t sell more than your current holdings. +
+ )} +======= {side === 'buy' && ( = ({ className="text-white/45" /> )} +>>>>>>> upstream/main {/* diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx index 1ac8448..9cb3fb7 100644 --- a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx +++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx @@ -130,4 +130,12 @@ describe('TradeDialog focus order', () => { ) ).toBeInTheDocument(); }); + + it('shows balance loading copy instead of stale holdings while switching networks', () => { + renderDialog({ availableHoldings: 10, isBalanceLoading: true }); + + expect(screen.getByText('Holdings: Loading...')).toBeInTheDocument(); + expect(screen.queryByText('Holdings: 10 keys')).not.toBeInTheDocument(); + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); }); diff --git a/src/hooks/__tests__/useNetworkAwareBalance.test.ts b/src/hooks/__tests__/useNetworkAwareBalance.test.ts new file mode 100644 index 0000000..0703def --- /dev/null +++ b/src/hooks/__tests__/useNetworkAwareBalance.test.ts @@ -0,0 +1,115 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useNetworkAwareBalance } from '@/hooks/useNetworkAwareBalance'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise(promiseResolve => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +describe('useNetworkAwareBalance', () => { + it('enters loading immediately during a network switch and clears it after the new balance loads', async () => { + const firstBalance = createDeferred(); + const secondBalance = createDeferred(); + const fetchFirstBalance = vi.fn(() => firstBalance.promise); + const fetchSecondBalance = vi.fn(() => secondBalance.promise); + + const { result, rerender } = renderHook( + ({ + balanceKey, + fetchBalance, + }: { + balanceKey: string; + fetchBalance: () => Promise; + }) => + useNetworkAwareBalance({ + balanceKey, + fetchBalance, + }), + { + initialProps: { + balanceKey: 'wallet:1', + fetchBalance: fetchFirstBalance, + }, + } + ); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + firstBalance.resolve(10); + await firstBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(10)); + expect(result.current.isLoading).toBe(false); + + rerender({ + balanceKey: 'wallet:2', + fetchBalance: fetchSecondBalance, + }); + + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + secondBalance.resolve(25); + await secondBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(25)); + expect(result.current.isLoading).toBe(false); + }); + + it('does not expose a stale balance when an older request resolves after a network switch', async () => { + const staleBalance = createDeferred(); + const currentBalance = createDeferred(); + const fetchStaleBalance = vi.fn(() => staleBalance.promise); + const fetchCurrentBalance = vi.fn(() => currentBalance.promise); + + const { result, rerender } = renderHook( + ({ + balanceKey, + fetchBalance, + }: { + balanceKey: string; + fetchBalance: () => Promise; + }) => + useNetworkAwareBalance({ + balanceKey, + fetchBalance, + }), + { + initialProps: { + balanceKey: 'wallet:1', + fetchBalance: fetchStaleBalance, + }, + } + ); + + rerender({ + balanceKey: 'wallet:2', + fetchBalance: fetchCurrentBalance, + }); + + await act(async () => { + staleBalance.resolve(10); + await staleBalance.promise; + }); + + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + currentBalance.resolve(30); + await currentBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(30)); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/src/hooks/useNetworkAwareBalance.ts b/src/hooks/useNetworkAwareBalance.ts new file mode 100644 index 0000000..f8b0eed --- /dev/null +++ b/src/hooks/useNetworkAwareBalance.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type BalanceStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface BalanceState { + balance: TBalance | null; + balanceKey: string | null; + error: unknown; + status: BalanceStatus; +} + +export interface UseNetworkAwareBalanceOptions { + /** + * Stable identity for the balance being fetched, usually account + chain + + * asset. Changing this key immediately hides the previous balance. + */ + balanceKey: string | null | undefined; + enabled?: boolean; + fetchBalance: () => Promise; +} + +export interface UseNetworkAwareBalanceResult { + balance: TBalance | null; + error: unknown; + isError: boolean; + isLoading: boolean; + refresh: () => void; +} + +export function useNetworkAwareBalance({ + balanceKey, + enabled = true, + fetchBalance, +}: UseNetworkAwareBalanceOptions): UseNetworkAwareBalanceResult { + const requestIdRef = useRef(0); + const [refreshNonce, setRefreshNonce] = useState(0); + const [state, setState] = useState>({ + balance: null, + balanceKey: null, + error: null, + status: 'idle', + }); + + const activeBalanceKey = enabled ? (balanceKey ?? null) : null; + const hasCurrentBalance = + state.status === 'success' && state.balanceKey === activeBalanceKey; + const isAwaitingCurrentBalance = + activeBalanceKey != null && + (state.balanceKey !== activeBalanceKey || state.status !== 'success'); + + useEffect(() => { + if (activeBalanceKey == null) { + requestIdRef.current += 1; + setState({ + balance: null, + balanceKey: null, + error: null, + status: 'idle', + }); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + let isActive = true; + setState({ + balance: null, + balanceKey: activeBalanceKey, + error: null, + status: 'loading', + }); + + fetchBalance() + .then(balance => { + if (!isActive || requestIdRef.current !== requestId) return; + setState({ + balance, + balanceKey: activeBalanceKey, + error: null, + status: 'success', + }); + }) + .catch(error => { + if (!isActive || requestIdRef.current !== requestId) return; + setState({ + balance: null, + balanceKey: activeBalanceKey, + error, + status: 'error', + }); + }); + + return () => { + isActive = false; + }; + }, [activeBalanceKey, fetchBalance, refreshNonce]); + + const refresh = useCallback(() => { + setRefreshNonce(nonce => nonce + 1); + }, []); + + return useMemo( + () => ({ + balance: hasCurrentBalance ? state.balance : null, + error: state.balanceKey === activeBalanceKey ? state.error : null, + isError: + state.balanceKey === activeBalanceKey && state.status === 'error', + isLoading: enabled && isAwaitingCurrentBalance, + refresh, + }), + [ + activeBalanceKey, + enabled, + hasCurrentBalance, + isAwaitingCurrentBalance, + refresh, + state.balance, + state.balanceKey, + state.error, + state.status, + ] + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index b61b52c..421e92d 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LayoutGroup, motion } from 'framer-motion'; +<<<<<<< HEAD +import { useAccount, useChainId } from 'wagmi'; +======= import { useSearchParams } from 'react-router'; +>>>>>>> upstream/main import { courseService, type Course } from '@/services/course.service'; import SkipToContent from '@/components/common/SkipToContent'; import { cn } from '@/lib/utils'; @@ -33,6 +37,7 @@ import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; +import { useNetworkAwareBalance } from '@/hooks/useNetworkAwareBalance'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -195,6 +200,7 @@ const CREATOR_SCROLL_KEY = 'accesslayer.creator-scrollY'; const MAX_CREATOR_FETCH_RETRIES = 3; const BASE_RETRY_DELAY_MS = 800; const PAGE_SIZE = 6; +const WALLET_HOLDINGS_BALANCE_LOAD_DELAY_MS = 250; const FETCH_RETRY_ACTION_LABEL = 'Try again'; const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const; const FINAL_FETCH_ERROR_COPY = @@ -205,6 +211,7 @@ const CREATOR_REFRESH_SHORTCUT_DURATION_MS = 1800; const getFetchRetryHelperCopy = (attempt: number, maxAttempts: number) => `We couldn't load live creators yet. Retrying automatically (attempt ${attempt} of ${maxAttempts}).`; +<<<<<<< HEAD const isEditableShortcutTarget = (target: EventTarget | null) => { if (!(target instanceof Element)) return false; @@ -228,8 +235,34 @@ const isCreatorRefreshShortcut = (event: KeyboardEvent) => !event.shiftKey && event.key.toLowerCase() === 'r'; +======= +const fetchFeaturedWalletHoldingsBalance = async (holdings: number) => { + await new Promise(resolve => + window.setTimeout(resolve, WALLET_HOLDINGS_BALANCE_LOAD_DELAY_MS) + ); + return holdings; +}; + +>>>>>>> 55dd18451378da76a8e2d9db9968461cce3556c6 type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; +const WalletBalanceLoadingIndicator: React.FC<{ className?: string }> = ({ + className, +}) => ( + + +); + interface CreatorProfileLoadErrorProps { onRetry: () => void; isRetrying: boolean; @@ -291,7 +324,7 @@ function LandingPage() { const hash = window.location.hash.slice(1); return PROFILE_TABS.includes(hash) ? hash : 'overview'; }); - const [featuredHoldings, setFeaturedHoldings] = useState(3); + const [featuredHoldingsSource, setFeaturedHoldingsSource] = useState(3); const [precisionMode, setPrecisionMode] = useState('compact'); const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); @@ -565,6 +598,36 @@ function LandingPage() { // 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]; + const walletBalanceKey = `${address ?? 'demo-wallet'}:${connectedChainId}:${featuredCreator.id}`; + const fetchFeaturedHoldingsBalance = useCallback( + () => fetchFeaturedWalletHoldingsBalance(featuredHoldingsSource), + [featuredHoldingsSource] + ); + const { + balance: featuredHoldings, + isLoading: isWalletBalanceLoading, + } = useNetworkAwareBalance({ + balanceKey: walletBalanceKey, + fetchBalance: fetchFeaturedHoldingsBalance, + }); + const displayedFeaturedHoldings = featuredHoldings ?? 0; + const formattedFeaturedOwnership = + featuredHoldings == null + ? '—' + : formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply, + { + maximumFractionDigits: precisionMode === 'compact' ? 1 : 2, + } + ); + const mobileFeaturedOwnership = + featuredHoldings == null + ? '—' + : formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply + ); useEffect(() => { if (pendingScrollRestoreRef.current == null) return; @@ -685,7 +748,9 @@ function LandingPage() { }; const handleConfirmTrade = async (amount: number) => { - const previousHoldings = featuredHoldings; + if (isWalletBalanceLoading || featuredHoldings == null) return; + + const previousHoldings = featuredHoldingsSource; setTradeSubmitting(true); try { @@ -697,7 +762,7 @@ function LandingPage() { await new Promise(resolve => window.setTimeout(resolve, 900)); - setFeaturedHoldings(current => + setFeaturedHoldingsSource(current => tradeSide === 'buy' ? current + amount : Math.max(0, current - amount) @@ -713,7 +778,7 @@ function LandingPage() { ); setTradeDialogOpen(false); } catch (error) { - setFeaturedHoldings(previousHoldings); + setFeaturedHoldingsSource(previousHoldings); showToast.error(getSignatureErrorMessage(error)); } finally { setTradeSubmitting(false); @@ -1285,7 +1350,9 @@ function LandingPage() { tradeSubmitting && 'pointer-events-none select-none opacity-60' )} - aria-busy={tradeSubmitting || undefined} + aria-busy={ + tradeSubmitting || isWalletBalanceLoading || undefined + } > @@ -1379,7 +1452,11 @@ function LandingPage() { size="sm" variant="outline" onClick={() => openTradeDialog('sell')} - disabled={isNetworkMismatch || tradeSubmitting} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isWalletBalanceLoading + } > Sell @@ -1413,6 +1490,7 @@ function LandingPage() { side={tradeSide} creatorName="Alex Rivers" availableHoldings={featuredHoldings} + isBalanceLoading={isWalletBalanceLoading} keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} onOpenChange={setTradeDialogOpen}