Skip to content

Commit 4f4e279

Browse files
authored
Merge pull request #404 from KidDev88/fix/4-issues
fix: resolve 4 issues - toast dedup, transaction history, session timeout, wallet modal
2 parents 1c83cb5 + 865fe1d commit 4f4e279

4 files changed

Lines changed: 217 additions & 29 deletions

File tree

components/wallet/WalletActivityHistory.tsx

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
11
"use client";
22

3-
import { memo } from 'react';
3+
import { memo, useRef } from 'react';
4+
import { useVirtualizer } from '@tanstack/react-virtual';
45
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui';
56
import { Button } from '@/components/ui';
67
import { EmptyState } from '@/components/shared';
7-
import { ArrowUpRight, ArrowDownLeft, Inbox, RefreshCcw, ExternalLink } from 'lucide-react';
8+
import { ArrowUpRight, ArrowDownLeft, Inbox, RefreshCcw, ExternalLink, Loader2 } from 'lucide-react';
89
import { getStellarExplorerTxUrl } from '@/lib/utils/explorer';
10+
import { useTransactionHistory } from '@/lib/hooks/useTransactionHistory';
911

1012
interface WalletTx {
1113
id: string;
1214
type: 'receive' | 'send';
1315
label: string;
1416
amount: number;
15-
time: string;
16-
txHash?: string;
17+
assetCode: string;
18+
timestamp: string;
19+
txHash: string;
20+
counterparty: string;
1721
}
1822

19-
const mockTxHistory: WalletTx[] = [
20-
{ id: 'w1', type: 'receive', label: 'Payment from link_02', amount: 45.5, time: '2h ago', txHash: '0x1234567890abcdef1234567890abcdef12345678' },
21-
{ id: 'w2', type: 'receive', label: 'Payment from link_01', amount: 750, time: '5h ago', txHash: '0xabcdef1234567890abcdef1234567890abcdef12' },
22-
{ id: 'w3', type: 'send', label: 'Settlement to GTBank', amount: 1200, time: 'Yesterday' },
23-
{ id: 'w4', type: 'receive', label: 'Payment from link_03', amount: 29, time: 'Yesterday', txHash: '0x9999999999abcdef1234567890abcdef12345678' },
24-
];
25-
2623
const WalletActivityItem = memo(function WalletActivityItem({ tx }: { tx: WalletTx }) {
2724
return (
2825
<div className="flex items-center gap-3 py-2.5 px-2 rounded-xl hover:bg-muted transition-colors">
@@ -35,10 +32,10 @@ const WalletActivityItem = memo(function WalletActivityItem({ tx }: { tx: Wallet
3532
</div>
3633
<div className="flex-1 min-w-0">
3734
<p className="text-sm font-medium text-foreground">{tx.label}</p>
38-
<p className="text-xs text-muted-foreground">{tx.time}</p>
35+
<p className="text-xs text-muted-foreground">{tx.timestamp}</p>
3936
</div>
4037
<span className={`text-sm font-semibold ${tx.type === 'receive' ? 'text-emerald-600' : 'text-foreground'}`}>
41-
{tx.type === 'receive' ? '+' : '-'}{tx.amount.toFixed(2)} USDC
38+
{tx.type === 'receive' ? '+' : '-'}{tx.amount.toFixed(2)} {tx.assetCode}
4239
</span>
4340
{tx.txHash && (
4441
<a
@@ -57,25 +54,83 @@ const WalletActivityItem = memo(function WalletActivityItem({ tx }: { tx: Wallet
5754
});
5855

5956
export function WalletActivityHistory() {
57+
const { transactions, loading, error, refetch } = useTransactionHistory();
58+
const parentRef = useRef<HTMLDivElement>(null);
59+
60+
const virtualizer = useVirtualizer({
61+
count: transactions.length,
62+
getScrollElement: () => parentRef.current,
63+
estimateSize: () => 60,
64+
overscan: 5,
65+
});
66+
6067
return (
6168
<Card className="border border-border bg-card shadow-sm">
6269
<CardHeader className="flex flex-row items-center justify-between">
6370
<div>
6471
<CardTitle className="text-base font-semibold text-foreground">Wallet Activity</CardTitle>
6572
<CardDescription>Recent on-chain transactions</CardDescription>
6673
</div>
67-
<Button variant="ghost" aria-label="Refresh balances" className="text-xs text-muted-foreground min-h-[44px] px-3 rounded-lg">
68-
<RefreshCcw className="w-3 h-3 mr-1.5" /> Refresh
74+
<Button
75+
variant="ghost"
76+
aria-label="Refresh transactions"
77+
onClick={refetch}
78+
disabled={loading}
79+
className="text-xs text-muted-foreground min-h-[44px] px-3 rounded-lg"
80+
>
81+
{loading ? (
82+
<Loader2 className="w-3 h-3 mr-1.5 animate-spin" />
83+
) : (
84+
<RefreshCcw className="w-3 h-3 mr-1.5" />
85+
)}{' '}
86+
Refresh
6987
</Button>
7088
</CardHeader>
7189
<CardContent>
72-
{mockTxHistory.length === 0 ? (
73-
<EmptyState icon={Inbox} title="No wallet activity yet" description="On-chain transactions will appear here once your wallet receives payments." />
90+
{loading && transactions.length === 0 ? (
91+
<div className="flex items-center justify-center py-8">
92+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
93+
</div>
94+
) : error ? (
95+
<EmptyState
96+
icon={Inbox}
97+
title="Failed to load transactions"
98+
description={error}
99+
/>
100+
) : transactions.length === 0 ? (
101+
<EmptyState
102+
icon={Inbox}
103+
title="No wallet activity yet"
104+
description="On-chain transactions will appear here once your wallet receives payments."
105+
/>
74106
) : (
75-
<div className="space-y-2">
76-
{mockTxHistory.map((tx) => (
77-
<WalletActivityItem key={tx.id} tx={tx} />
78-
))}
107+
<div
108+
ref={parentRef}
109+
className="h-[300px] overflow-auto"
110+
>
111+
<div
112+
style={{
113+
height: `${virtualizer.getTotalSize()}px`,
114+
width: '100%',
115+
position: 'relative',
116+
}}
117+
>
118+
{virtualizer.getVirtualItems().map((virtualRow) => (
119+
<div
120+
key={virtualRow.key}
121+
style={{
122+
position: 'absolute',
123+
top: 0,
124+
left: 0,
125+
width: '100%',
126+
height: `${virtualRow.size}px`,
127+
transform: `translateY(${virtualRow.start}px)`,
128+
}}
129+
>
130+
<WalletActivityItem tx={transactions[virtualRow.index]} />
131+
</div>
132+
))}
133+
</div>
79134
</div>
80135
)}
81136
</CardContent>

lib/api/axios.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,31 @@ import { announce } from '@/lib/utils/announce';
77
import { parseApiError } from '../utils/apiError';
88
import { getAppRouter } from '../navigation/appRouter';
99

10-
function notifyError(message: string) {
10+
// Deduplication: avoid showing multiple toasts for simultaneous errors
11+
const recentErrors = new Map<string, number>();
12+
const ERROR_DEDUP_WINDOW_MS = 3000;
13+
14+
function notifyError(message: string, key?: string) {
15+
const dedupKey = key || message;
16+
const now = Date.now();
17+
const lastShown = recentErrors.get(dedupKey);
18+
19+
if (lastShown && now - lastShown < ERROR_DEDUP_WINDOW_MS) {
20+
return;
21+
}
22+
23+
recentErrors.set(dedupKey, now);
1124
toast.error(message, { duration: 5000 });
1225
announce(message);
26+
27+
// Clean up old entries
28+
if (recentErrors.size > 50) {
29+
for (const [k, v] of recentErrors) {
30+
if (now - v > ERROR_DEDUP_WINDOW_MS) {
31+
recentErrors.delete(k);
32+
}
33+
}
34+
}
1335
}
1436

1537
// Use cookie-based auth (HttpOnly cookie set by the server). Do not read tokens from localStorage.
@@ -145,10 +167,9 @@ apiClient.interceptors.response.use(
145167
useRateLimitStore.getState().setRateLimited(seconds);
146168
notifyError(`Too many attempts. Please try again in ${seconds} seconds.`);
147169
} else if (!error.response) {
148-
// Show toast for network errors
149-
notifyError('Network error. Please check your connection.');
170+
notifyError('Network error. Please check your connection.', 'network_error');
150171
} else if (error.response?.status >= 500) {
151-
notifyError('A server error occurred. Please try again later.');
172+
notifyError('A server error occurred. Please try again later.', `5xx_${error.response.status}`);
152173
}
153174

154175
return Promise.reject(parseApiError(error));

lib/hooks/useSessionTimeout.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,8 @@ export function useSessionTimeout({
8585
resetTimer();
8686

8787
const handleActivity = () => {
88-
if (!showWarning) {
89-
resetTimer();
90-
}
88+
// Reset timer on any activity, whether modal is showing or not
89+
resetTimer();
9190
};
9291

9392
ACTIVITY_EVENTS.forEach((event) => {
@@ -100,7 +99,7 @@ export function useSessionTimeout({
10099
document.removeEventListener(event, handleActivity);
101100
});
102101
};
103-
}, [resetTimer, clearAllTimers, showWarning]);
102+
}, [resetTimer, clearAllTimers]);
104103

105104
return { showWarning, secondsRemaining, dismissWarning };
106105
}

lib/hooks/useTransactionHistory.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use client';
2+
3+
import { useState, useEffect, useCallback } from 'react';
4+
import { useWalletStore } from '@/lib/store/walletStore';
5+
6+
const NETWORK_URLS: Record<string, string> = {
7+
testnet: 'https://horizon-testnet.stellar.org',
8+
public: 'https://horizon.stellar.org',
9+
};
10+
11+
interface StellarPayment {
12+
id: string;
13+
type: 'receive' | 'send';
14+
label: string;
15+
amount: number;
16+
assetCode: string;
17+
timestamp: string;
18+
txHash: string;
19+
counterparty: string;
20+
}
21+
22+
function getNetwork(): 'testnet' | 'public' {
23+
const val = (process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet').toLowerCase();
24+
if (val === 'mainnet' || val === 'public') return 'public';
25+
return 'testnet';
26+
}
27+
28+
function formatTimeAgo(dateString: string): string {
29+
const now = new Date();
30+
const then = new Date(dateString);
31+
const diffMs = now.getTime() - then.getTime();
32+
const diffSec = Math.floor(diffMs / 1000);
33+
const diffMin = Math.floor(diffSec / 60);
34+
const diffHr = Math.floor(diffMin / 60);
35+
const diffDay = Math.floor(diffHr / 24);
36+
37+
if (diffDay > 0) return `${diffDay}d ago`;
38+
if (diffHr > 0) return `${diffHr}h ago`;
39+
if (diffMin > 0) return `${diffMin}m ago`;
40+
return 'Just now';
41+
}
42+
43+
export function useTransactionHistory(limit = 20) {
44+
const [transactions, setTransactions] = useState<StellarPayment[]>([]);
45+
const [loading, setLoading] = useState(false);
46+
const [error, setError] = useState<string | null>(null);
47+
const address = useWalletStore((s) => s.address);
48+
const network = useWalletStore((s) => s.network);
49+
50+
const fetchTransactions = useCallback(async () => {
51+
if (!address) return;
52+
53+
setLoading(true);
54+
setError(null);
55+
56+
const horizonUrl = NETWORK_URLS[network] || NETWORK_URLS[getNetwork()];
57+
58+
try {
59+
const response = await fetch(
60+
`${horizonUrl}/accounts/${address}/payments?limit=${limit}&order=desc`
61+
);
62+
63+
if (!response.ok) {
64+
throw new Error(`Horizon error: ${response.status} ${response.statusText}`);
65+
}
66+
67+
const data = await response.json();
68+
const payments: StellarPayment[] = data._embedded.records.map(
69+
(record: {
70+
id: string;
71+
from: string;
72+
to: string;
73+
amount: string;
74+
asset_type: string;
75+
asset_code?: string;
76+
created_at: string;
77+
transaction_hash: string;
78+
}) => {
79+
const isReceive = record.to === address;
80+
const assetCode =
81+
record.asset_type === 'native'
82+
? 'XLM'
83+
: record.asset_code || 'Unknown';
84+
const counterparty = isReceive ? record.from : record.to;
85+
const shortAddress = `${counterparty.slice(0, 4)}...${counterparty.slice(-4)}`;
86+
87+
return {
88+
id: record.id,
89+
type: isReceive ? 'receive' : 'send',
90+
label: `Payment ${isReceive ? 'from' : 'to'} ${shortAddress}`,
91+
amount: parseFloat(record.amount),
92+
assetCode,
93+
timestamp: formatTimeAgo(record.created_at),
94+
txHash: record.transaction_hash,
95+
counterparty,
96+
};
97+
}
98+
);
99+
100+
setTransactions(payments);
101+
} catch (err) {
102+
setError(err instanceof Error ? err.message : 'Failed to fetch transactions');
103+
} finally {
104+
setLoading(false);
105+
}
106+
}, [address, network, limit]);
107+
108+
useEffect(() => {
109+
fetchTransactions();
110+
}, [fetchTransactions]);
111+
112+
return { transactions, loading, error, refetch: fetchTransactions };
113+
}

0 commit comments

Comments
 (0)