diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index fa3e5508..849e4c60 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,8 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; export function useWalletHoldings(address: string) { - return useQuery({ + return useQuery({ queryKey: queryKeys.wallet.holdings(address), queryFn: async () => [], enabled: !!address, @@ -16,3 +19,81 @@ export function useWalletActivity(address: string) { enabled: !!address, }); } + +export interface TradeVariables { + creatorId: string; + amount: number; + priceStroops: number | null | undefined; + price: number | null | undefined; +} + +export function useTradeMutation(address: string) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationKey: ['trade', address], + mutationFn: async () => { + await new Promise(resolve => window.setTimeout(resolve, 900)); + return { success: true as const }; + }, + onMutate: async ({ creatorId, amount, priceStroops, price }: TradeVariables) => { + const queryKey = queryKeys.wallet.holdings(address); + + await queryClient.cancelQueries({ queryKey }); + + const previousHoldings = queryClient.getQueryData(queryKey) ?? []; + + queryClient.setQueryData(queryKey, (old = []) => { + const existing = old.find(h => h.creatorId === creatorId); + if (existing) { + return old.map(h => + h.creatorId === creatorId + ? { ...h, quantity: (h.quantity ?? 0) + amount, pending: true } + : h + ); + } + return [ + ...old, + { + creatorId, + quantity: amount, + priceStroops: priceStroops ?? null, + price: price ?? null, + pending: true, + }, + ]; + }); + + return { previousHoldings }; + }, + onError: (error, _variables, context) => { + if (context?.previousHoldings) { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + context.previousHoldings + ); + } + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + (old = []) => + old.map(h => + h.creatorId === variables.creatorId + ? { ...h, pending: false } + : h + ) + ); + showToast.transactionSuccess( + 'Trade confirmed', + `Holdings refreshed: +${variables.amount} keys.` + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) }); + }, + }); + + return mutation; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 58ab6947..36448de2 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -38,6 +38,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 { useTradeMutation, useWalletHoldings } from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -185,6 +186,7 @@ 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 DEMO_WALLET_ADDRESS = 'demo-wallet-address'; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; const CREATOR_REFRESH_SHORTCUT_LABEL = 'Ctrl/Cmd + Alt + R'; @@ -714,20 +716,28 @@ function LandingPage() { handleRetryCreatorFetch(); }; + const tradeMutation = useTradeMutation(DEMO_WALLET_ADDRESS); + const { data: cachedHoldings = [] } = useWalletHoldings(DEMO_WALLET_ADDRESS); + const heldKeyPositions = useMemo( () => - holdingsCreators.map((creator, index) => ({ - creatorId: creator.id, - quantity: + holdingsCreators.map((creator, index) => { + const cached = cachedHoldings.find(h => h.creatorId === creator.id); + const baseQuantity = index === 0 ? featuredHoldings - : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0), - priceStroops: creator.priceStroops, - price: creator.price, - isPriceLoading: isPriceRefreshing, - isPriceStale: creatorsAreStale, - })), - [holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing] + : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0); + return { + creatorId: creator.id, + quantity: cached?.quantity ?? baseQuantity, + priceStroops: creator.priceStroops, + price: creator.price, + isPriceLoading: isPriceRefreshing, + isPriceStale: creatorsAreStale, + pending: cached?.pending ?? false, + }; + }), + [holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing, cachedHoldings] ); const portfolioValue = useMemo( () => calculatePortfolioValue(heldKeyPositions), @@ -784,37 +794,37 @@ function LandingPage() { }; const handleConfirmTrade = async (amount: number) => { - const previousHoldings = featuredHoldings; - const creatorName = featuredCreator?.title ?? 'Unknown creator'; setTradeSubmitting(true); try { - showToast.loading( - tradeSide === 'buy' - ? `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` - : `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...` - ); - - await new Promise(resolve => window.setTimeout(resolve, 900)); - - setFeaturedHoldings(current => - tradeSide === 'buy' - ? current + amount - : Math.max(0, current - amount) - ); - - await new Promise(resolve => window.setTimeout(resolve, 250)); - - showToast.transactionSuccess( - 'Trade confirmed', - tradeSide === 'buy' - ? `Bought ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${creatorName}` - : `Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${creatorName}` - ); + if (tradeSide === 'buy') { + showToast.loading( + `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` + ); + await tradeMutation.mutateAsync({ + creatorId: '1', + amount, + priceStroops: resolveCreatorKeyPriceStroops(featuredCreator), + price: featuredCreator?.price, + }); + setFeaturedHoldings(current => current + amount); + } else { + showToast.loading( + `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...` + ); + await new Promise(resolve => window.setTimeout(resolve, 900)); + setFeaturedHoldings(current => Math.max(0, current - amount)); + await new Promise(resolve => window.setTimeout(resolve, 250)); + showToast.transactionSuccess( + 'Trade confirmed', + `Holdings refreshed: -${formatNumber(amount)} keys.` + ); + } setTradeDialogOpen(false); } catch (error) { - setFeaturedHoldings(previousHoldings); - showToast.error(getSignatureErrorMessage(error)); + if (tradeSide === 'sell') { + showToast.error(getSignatureErrorMessage(error)); + } } finally { setTradeSubmitting(false); } @@ -1355,12 +1365,21 @@ function LandingPage() { return (
{creator?.title ?? 'Unknown creator'}
+ {position.pending && ( + + + Pending + + )} {formatNumber(position.quantity)} keys ยท{' '} {position.isPriceLoading ? 'Refreshing price' diff --git a/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx b/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx index ec5f5aee..a34c9191 100644 --- a/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx +++ b/src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx @@ -6,6 +6,11 @@ import LandingPage from '@/pages/LandingPage'; import { courseService } from '@/services/course.service'; import showToast from '@/utils/toast.util'; +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + vi.mock('@/services/course.service', () => ({ courseService: { getCourses: vi.fn() }, })); diff --git a/src/pages/__tests__/LandingPage.holdings.test.tsx b/src/pages/__tests__/LandingPage.holdings.test.tsx index c6e531bb..af891e3d 100644 --- a/src/pages/__tests__/LandingPage.holdings.test.tsx +++ b/src/pages/__tests__/LandingPage.holdings.test.tsx @@ -5,6 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import LandingPage from '@/pages/LandingPage'; import { courseService, type Course } from '@/services/course.service'; +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + vi.mock('@/services/course.service', () => ({ courseService: { getCourses: vi.fn(), diff --git a/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx index 7cc0c6fd..d2fa6a68 100644 --- a/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx +++ b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx @@ -12,6 +12,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import LandingPage from '@/pages/LandingPage'; import { courseService, type Course } from '@/services/course.service'; +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + vi.mock('@/services/course.service', () => ({ courseService: { getCourses: vi.fn() }, })); diff --git a/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx b/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx index 7ae7d74e..db48da28 100644 --- a/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx +++ b/src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx @@ -13,6 +13,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import LandingPage from '@/pages/LandingPage'; import { courseService } from '@/services/course.service'; +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + vi.mock('@/services/course.service', () => ({ courseService: { getCourses: vi.fn() }, })); diff --git a/src/utils/portfolioValue.utils.ts b/src/utils/portfolioValue.utils.ts index 07f1d9f0..1676d709 100644 --- a/src/utils/portfolioValue.utils.ts +++ b/src/utils/portfolioValue.utils.ts @@ -9,6 +9,7 @@ export interface HeldKeyPosition extends CreatorKeyPriceFields { quantity: number | null | undefined; isPriceLoading?: boolean; isPriceStale?: boolean; + pending?: boolean; } export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable';