Skip to content

Commit 773343e

Browse files
authored
Merge pull request #614 from Hollujay/feat/571-optimistic-buy-transaction-ui
feat: add optimistic UI update for buy transaction before on-chain confirmation (#571)
2 parents 840dcad + f95c4af commit 773343e

7 files changed

Lines changed: 160 additions & 39 deletions

src/hooks/useWallet.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { useQuery } from '@tanstack/react-query';
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
22
import { queryKeys } from '@/lib/queryKeys';
3+
import type { HeldKeyPosition } from '@/utils/portfolioValue.utils';
4+
import showToast from '@/utils/toast.util';
5+
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
36

47
export function useWalletHoldings(address: string) {
5-
return useQuery({
8+
return useQuery<HeldKeyPosition[]>({
69
queryKey: queryKeys.wallet.holdings(address),
710
queryFn: async () => [],
811
enabled: !!address,
@@ -16,3 +19,81 @@ export function useWalletActivity(address: string) {
1619
enabled: !!address,
1720
});
1821
}
22+
23+
export interface TradeVariables {
24+
creatorId: string;
25+
amount: number;
26+
priceStroops: number | null | undefined;
27+
price: number | null | undefined;
28+
}
29+
30+
export function useTradeMutation(address: string) {
31+
const queryClient = useQueryClient();
32+
33+
const mutation = useMutation({
34+
mutationKey: ['trade', address],
35+
mutationFn: async () => {
36+
await new Promise<void>(resolve => window.setTimeout(resolve, 900));
37+
return { success: true as const };
38+
},
39+
onMutate: async ({ creatorId, amount, priceStroops, price }: TradeVariables) => {
40+
const queryKey = queryKeys.wallet.holdings(address);
41+
42+
await queryClient.cancelQueries({ queryKey });
43+
44+
const previousHoldings = queryClient.getQueryData<HeldKeyPosition[]>(queryKey) ?? [];
45+
46+
queryClient.setQueryData<HeldKeyPosition[]>(queryKey, (old = []) => {
47+
const existing = old.find(h => h.creatorId === creatorId);
48+
if (existing) {
49+
return old.map(h =>
50+
h.creatorId === creatorId
51+
? { ...h, quantity: (h.quantity ?? 0) + amount, pending: true }
52+
: h
53+
);
54+
}
55+
return [
56+
...old,
57+
{
58+
creatorId,
59+
quantity: amount,
60+
priceStroops: priceStroops ?? null,
61+
price: price ?? null,
62+
pending: true,
63+
},
64+
];
65+
});
66+
67+
return { previousHoldings };
68+
},
69+
onError: (error, _variables, context) => {
70+
if (context?.previousHoldings) {
71+
queryClient.setQueryData(
72+
queryKeys.wallet.holdings(address),
73+
context.previousHoldings
74+
);
75+
}
76+
showToast.error(getSignatureErrorMessage(error));
77+
},
78+
onSuccess: (_data, variables) => {
79+
queryClient.setQueryData<HeldKeyPosition[]>(
80+
queryKeys.wallet.holdings(address),
81+
(old = []) =>
82+
old.map(h =>
83+
h.creatorId === variables.creatorId
84+
? { ...h, pending: false }
85+
: h
86+
)
87+
);
88+
showToast.transactionSuccess(
89+
'Trade confirmed',
90+
`Holdings refreshed: +${variables.amount} keys.`
91+
);
92+
},
93+
onSettled: () => {
94+
queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) });
95+
},
96+
});
97+
98+
return mutation;
99+
}

src/pages/LandingPage.tsx

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
3838
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
3939
import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge';
4040
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
41+
import { useTradeMutation, useWalletHoldings } from '@/hooks/useWallet';
4142
import showToast from '@/utils/toast.util';
4243
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
4344
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
@@ -185,6 +186,7 @@ const BASE_RETRY_DELAY_MS = 800;
185186
const PAGE_SIZE = 6;
186187
const FETCH_RETRY_ACTION_LABEL = 'Try again';
187188
const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const;
189+
const DEMO_WALLET_ADDRESS = 'demo-wallet-address';
188190
const FINAL_FETCH_ERROR_COPY =
189191
'Unable to load live creators right now. Showing fallback creators.';
190192
const CREATOR_REFRESH_SHORTCUT_LABEL = 'Ctrl/Cmd + Alt + R';
@@ -714,20 +716,28 @@ function LandingPage() {
714716
handleRetryCreatorFetch();
715717
};
716718

719+
const tradeMutation = useTradeMutation(DEMO_WALLET_ADDRESS);
720+
const { data: cachedHoldings = [] } = useWalletHoldings(DEMO_WALLET_ADDRESS);
721+
717722
const heldKeyPositions = useMemo(
718723
() =>
719-
holdingsCreators.map((creator, index) => ({
720-
creatorId: creator.id,
721-
quantity:
724+
holdingsCreators.map((creator, index) => {
725+
const cached = cachedHoldings.find(h => h.creatorId === creator.id);
726+
const baseQuantity =
722727
index === 0
723728
? featuredHoldings
724-
: (DEMO_HELD_KEY_QUANTITIES[index] ?? 0),
725-
priceStroops: creator.priceStroops,
726-
price: creator.price,
727-
isPriceLoading: isPriceRefreshing,
728-
isPriceStale: creatorsAreStale,
729-
})),
730-
[holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing]
729+
: (DEMO_HELD_KEY_QUANTITIES[index] ?? 0);
730+
return {
731+
creatorId: creator.id,
732+
quantity: cached?.quantity ?? baseQuantity,
733+
priceStroops: creator.priceStroops,
734+
price: creator.price,
735+
isPriceLoading: isPriceRefreshing,
736+
isPriceStale: creatorsAreStale,
737+
pending: cached?.pending ?? false,
738+
};
739+
}),
740+
[holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing, cachedHoldings]
731741
);
732742
const portfolioValue = useMemo(
733743
() => calculatePortfolioValue(heldKeyPositions),
@@ -784,37 +794,37 @@ function LandingPage() {
784794
};
785795

786796
const handleConfirmTrade = async (amount: number) => {
787-
const previousHoldings = featuredHoldings;
788-
const creatorName = featuredCreator?.title ?? 'Unknown creator';
789797
setTradeSubmitting(true);
790798

791799
try {
792-
showToast.loading(
793-
tradeSide === 'buy'
794-
? `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...`
795-
: `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...`
796-
);
797-
798-
await new Promise<void>(resolve => window.setTimeout(resolve, 900));
799-
800-
setFeaturedHoldings(current =>
801-
tradeSide === 'buy'
802-
? current + amount
803-
: Math.max(0, current - amount)
804-
);
805-
806-
await new Promise<void>(resolve => window.setTimeout(resolve, 250));
807-
808-
showToast.transactionSuccess(
809-
'Trade confirmed',
810-
tradeSide === 'buy'
811-
? `Bought ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${creatorName}`
812-
: `Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${creatorName}`
813-
);
800+
if (tradeSide === 'buy') {
801+
showToast.loading(
802+
`Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...`
803+
);
804+
await tradeMutation.mutateAsync({
805+
creatorId: '1',
806+
amount,
807+
priceStroops: resolveCreatorKeyPriceStroops(featuredCreator),
808+
price: featuredCreator?.price,
809+
});
810+
setFeaturedHoldings(current => current + amount);
811+
} else {
812+
showToast.loading(
813+
`Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...`
814+
);
815+
await new Promise<void>(resolve => window.setTimeout(resolve, 900));
816+
setFeaturedHoldings(current => Math.max(0, current - amount));
817+
await new Promise<void>(resolve => window.setTimeout(resolve, 250));
818+
showToast.transactionSuccess(
819+
'Trade confirmed',
820+
`Holdings refreshed: -${formatNumber(amount)} keys.`
821+
);
822+
}
814823
setTradeDialogOpen(false);
815824
} catch (error) {
816-
setFeaturedHoldings(previousHoldings);
817-
showToast.error(getSignatureErrorMessage(error));
825+
if (tradeSide === 'sell') {
826+
showToast.error(getSignatureErrorMessage(error));
827+
}
818828
} finally {
819829
setTradeSubmitting(false);
820830
}
@@ -1355,12 +1365,21 @@ function LandingPage() {
13551365
return (
13561366
<div
13571367
key={position.creatorId}
1358-
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
1368+
className={cn(
1369+
'rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-opacity',
1370+
position.pending && 'opacity-60'
1371+
)}
13591372
>
13601373
<div className="truncate text-sm font-bold text-white">
13611374
{creator?.title ?? 'Unknown creator'}
13621375
</div>
13631376
<div className="mt-1 text-xs text-white/55">
1377+
{position.pending && (
1378+
<span className="mr-2 inline-flex items-center gap-1 rounded-full bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-400">
1379+
<span className="size-2.5 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" />
1380+
Pending
1381+
</span>
1382+
)}
13641383
{formatNumber(position.quantity)} keys ·{' '}
13651384
{position.isPriceLoading
13661385
? 'Refreshing price'

src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import LandingPage from '@/pages/LandingPage';
66
import { courseService } from '@/services/course.service';
77
import showToast from '@/utils/toast.util';
88

9+
vi.mock('@/hooks/useWallet', () => ({
10+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
11+
useWalletHoldings: () => ({ data: [] }),
12+
}));
13+
914
vi.mock('@/services/course.service', () => ({
1015
courseService: { getCourses: vi.fn() },
1116
}));

src/pages/__tests__/LandingPage.holdings.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
55
import LandingPage from '@/pages/LandingPage';
66
import { courseService, type Course } from '@/services/course.service';
77

8+
vi.mock('@/hooks/useWallet', () => ({
9+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
10+
useWalletHoldings: () => ({ data: [] }),
11+
}));
12+
813
vi.mock('@/services/course.service', () => ({
914
courseService: {
1015
getCourses: vi.fn(),

src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
1212
import LandingPage from '@/pages/LandingPage';
1313
import { courseService, type Course } from '@/services/course.service';
1414

15+
vi.mock('@/hooks/useWallet', () => ({
16+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
17+
useWalletHoldings: () => ({ data: [] }),
18+
}));
19+
1520
vi.mock('@/services/course.service', () => ({
1621
courseService: { getCourses: vi.fn() },
1722
}));

src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
1313
import LandingPage from '@/pages/LandingPage';
1414
import { courseService } from '@/services/course.service';
1515

16+
vi.mock('@/hooks/useWallet', () => ({
17+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
18+
useWalletHoldings: () => ({ data: [] }),
19+
}));
20+
1621
vi.mock('@/services/course.service', () => ({
1722
courseService: { getCourses: vi.fn() },
1823
}));

src/utils/portfolioValue.utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface HeldKeyPosition extends CreatorKeyPriceFields {
99
quantity: number | null | undefined;
1010
isPriceLoading?: boolean;
1111
isPriceStale?: boolean;
12+
pending?: boolean;
1213
}
1314

1415
export type PortfolioValueStatus = 'ready' | 'loading' | 'unavailable';

0 commit comments

Comments
 (0)