Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions src/hooks/useWallet.ts
Original file line number Diff line number Diff line change
@@ -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<HeldKeyPosition[]>({
queryKey: queryKeys.wallet.holdings(address),
queryFn: async () => [],
enabled: !!address,
Expand All @@ -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<void>(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<HeldKeyPosition[]>(queryKey) ?? [];

queryClient.setQueryData<HeldKeyPosition[]>(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<HeldKeyPosition[]>(
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;
}
93 changes: 56 additions & 37 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<void>(resolve => window.setTimeout(resolve, 900));

setFeaturedHoldings(current =>
tradeSide === 'buy'
? current + amount
: Math.max(0, current - amount)
);

await new Promise<void>(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<void>(resolve => window.setTimeout(resolve, 900));
setFeaturedHoldings(current => Math.max(0, current - amount));
await new Promise<void>(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);
}
Expand Down Expand Up @@ -1355,12 +1365,21 @@ function LandingPage() {
return (
<div
key={position.creatorId}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
className={cn(
'rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-opacity',
position.pending && 'opacity-60'
)}
>
<div className="truncate text-sm font-bold text-white">
{creator?.title ?? 'Unknown creator'}
</div>
<div className="mt-1 text-xs text-white/55">
{position.pending && (
<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">
<span className="size-2.5 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" />
Pending
</span>
)}
{formatNumber(position.quantity)} keys ·{' '}
{position.isPriceLoading
? 'Refreshing price'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}));
Expand Down
5 changes: 5 additions & 0 deletions src/pages/__tests__/LandingPage.holdings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}));
Expand Down
5 changes: 5 additions & 0 deletions src/pages/__tests__/LandingPage.holdingsEmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}));
Expand Down
1 change: 1 addition & 0 deletions src/utils/portfolioValue.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading