Skip to content

Commit 033e818

Browse files
committed
Improve Connection Button
1 parent bce979a commit 033e818

5 files changed

Lines changed: 276 additions & 38 deletions

File tree

frontend/package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/WalletConnect.test.tsx

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ describe('WalletConnect', () => {
4747
expect(screen.getByText(/Connect Freighter/i)).toBeInTheDocument();
4848
});
4949

50+
it('shows loading state while connecting', async () => {
51+
mockedFreighter.isAllowed.mockResolvedValue({ isAllowed: false });
52+
mockedFreighter.setAllowed.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
53+
54+
render(
55+
<WalletConnectWrapper
56+
walletAddress={null}
57+
onConnect={mockOnConnect}
58+
onDisconnect={mockOnDisconnect}
59+
/>
60+
);
61+
62+
const button = screen.getByText(/Connect Freighter/i);
63+
fireEvent.click(button);
64+
65+
// Should show connecting state
66+
expect(screen.getByText(/Connecting/i)).toBeInTheDocument();
67+
});
68+
5069
it('calls onConnect when manually connected via button', async () => {
5170
mockedFreighter.isAllowed
5271
.mockResolvedValueOnce({ isAllowed: false })
@@ -70,6 +89,89 @@ describe('WalletConnect', () => {
7089
});
7190
});
7291

92+
it('shows error state when permission is denied', async () => {
93+
mockedFreighter.isAllowed.mockResolvedValueOnce({ isAllowed: false });
94+
mockedFreighter.setAllowed.mockResolvedValue({});
95+
mockedFreighter.getAddress.mockResolvedValue({ address: undefined });
96+
97+
render(
98+
<WalletConnectWrapper
99+
walletAddress={null}
100+
onConnect={mockOnConnect}
101+
onDisconnect={mockOnDisconnect}
102+
/>
103+
);
104+
105+
const button = screen.getByText(/Connect Freighter/i);
106+
fireEvent.click(button);
107+
108+
await waitFor(() => {
109+
expect(mockOnConnect).not.toHaveBeenCalled();
110+
});
111+
});
112+
113+
it('shows error state when connection fails', async () => {
114+
mockedFreighter.setAllowed.mockRejectedValueOnce(new Error('Freighter not found'));
115+
116+
render(
117+
<WalletConnectWrapper
118+
walletAddress={null}
119+
onConnect={mockOnConnect}
120+
onDisconnect={mockOnDisconnect}
121+
/>
122+
);
123+
124+
const button = screen.getByText(/Connect Freighter/i);
125+
fireEvent.click(button);
126+
127+
await waitFor(() => {
128+
expect(mockOnConnect).not.toHaveBeenCalled();
129+
});
130+
});
131+
132+
it('displays tooltip on button hover', async () => {
133+
mockedFreighter.isAllowed.mockResolvedValue({ isAllowed: false });
134+
135+
render(
136+
<WalletConnectWrapper
137+
walletAddress={null}
138+
onConnect={mockOnConnect}
139+
onDisconnect={mockOnDisconnect}
140+
/>
141+
);
142+
143+
const button = screen.getByText(/Connect Freighter/i).closest('button');
144+
if (!button) throw new Error('Button not found');
145+
146+
fireEvent.mouseEnter(button);
147+
148+
// Tooltip should appear
149+
await waitFor(() => {
150+
expect(button).toHaveAttribute('title');
151+
});
152+
});
153+
154+
it('hides tooltip on button mouse leave', async () => {
155+
mockedFreighter.isAllowed.mockResolvedValue({ isAllowed: false });
156+
157+
render(
158+
<WalletConnectWrapper
159+
walletAddress={null}
160+
onConnect={mockOnConnect}
161+
onDisconnect={mockOnDisconnect}
162+
/>
163+
);
164+
165+
const button = screen.getByText(/Connect Freighter/i).closest('button');
166+
if (!button) throw new Error('Button not found');
167+
168+
fireEvent.mouseEnter(button);
169+
fireEvent.mouseLeave(button);
170+
171+
// Button should have title attribute for accessibility fallback
172+
expect(button).toHaveAttribute('title');
173+
});
174+
73175
it('shows the formatted address when connected', () => {
74176
const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012';
75177
const expectedAddress = 'GABC1...9012';
@@ -100,12 +202,7 @@ describe('WalletConnect', () => {
100202
expect(mockOnDisconnect).toHaveBeenCalled();
101203
});
102204

103-
it('polls wallet permissions on an interval', async () => {
104-
vi.useFakeTimers();
105205
it('handles wallet disconnects gracefully during polling', async () => {
106-
// Helper to flush all pending promises
107-
const flushPromises = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
108-
109206
vi.useFakeTimers({ shouldAdvanceTime: false });
110207
mockedFreighter.isAllowed
111208
.mockResolvedValueOnce({ isAllowed: true })
@@ -128,17 +225,7 @@ describe('WalletConnect', () => {
128225
});
129226

130227
expect(mockedFreighter.isAllowed.mock.calls.length).toBeGreaterThanOrEqual(2);
228+
229+
vi.useRealTimers();
131230
});
132-
// Advance timers by 0 to flush the initial synchronous setup,
133-
// then flush microtasks from the async calls
134-
vi.advanceTimersByTime(0);
135-
await vi.advanceTimersByTimeAsync(0);
136-
137-
expect(mockOnConnect).toHaveBeenCalledWith('GABC123');
138-
139-
// Advance past the 10s polling interval
140-
await vi.advanceTimersByTimeAsync(10001);
141-
142-
expect(mockOnDisconnect).toHaveBeenCalled();
143-
}, 20000);
144231
});

frontend/src/components/WalletConnect.tsx

Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import React, { useState, useEffect, useRef } from "react";
2-
import { setAllowed } from "@stellar/freighter-api";
3-
import React, { useState, useEffect } from "react";
42
import { setAllowed, isAllowed, getAddress } from "@stellar/freighter-api";
5-
import { Loader2, LogOut, Wallet } from './icons';
3+
import { Loader2, LogOut, Wallet, AlertCircle, Check } from './icons';
64
import { hasCustomRpcConfig, networkConfig } from '../config/network';
75
import { useToast } from '../context/ToastContext';
86
import { useTranslation } from '../i18n';
@@ -15,8 +13,13 @@ interface WalletConnectProps {
1513
onDisconnect: () => void;
1614
}
1715

16+
type ConnectionErrorType = 'not-installed' | 'not-allowed' | 'no-address' | 'generic' | null;
17+
1818
const WalletConnect: React.FC<WalletConnectProps> = ({ walletAddress, onConnect, onDisconnect }) => {
1919
const [isConnecting, setIsConnecting] = useState(false);
20+
const [connectionError, setConnectionError] = useState<ConnectionErrorType>(null);
21+
const [showTooltip, setShowTooltip] = useState(false);
22+
const buttonRef = useRef<HTMLButtonElement>(null);
2023
const announcedAddressRef = useRef<string | null>(null);
2124
const toast = useToast();
2225
const { t } = useTranslation();
@@ -64,38 +67,51 @@ const WalletConnect: React.FC<WalletConnectProps> = ({ walletAddress, onConnect,
6467

6568
const handleConnect = async () => {
6669
setIsConnecting(true);
70+
setConnectionError(null);
6771
try {
6872
await setAllowed();
6973
const allowed = await isAllowed();
7074
if (allowed.isAllowed) {
7175
const userInfo = await getAddress();
7276
if (userInfo.address) {
7377
onConnect(userInfo.address);
78+
setConnectionError(null);
7479
toast.success({
7580
title: t('toast.walletConnected.title'),
7681
description: t('toast.walletConnected.description'),
7782
});
83+
return;
84+
} else {
85+
setConnectionError('no-address');
86+
toast.error({
87+
title: t('toast.walletConnectionFailed.title'),
88+
description: t('wallet.error.noAddress'),
89+
});
90+
return;
7891
}
79-
}
80-
const discoveredAddress = await discoverConnectedAddress();
81-
if (discoveredAddress) {
82-
onConnect(discoveredAddress);
83-
toast.success({
84-
title: "Wallet connected",
85-
description: "Freighter is now connected to your YieldVault session.",
86-
});
8792
} else {
93+
setConnectionError('not-allowed');
8894
toast.warning({
8995
title: t('toast.walletPermissionRequired.title'),
90-
description: t('toast.walletPermissionRequired.description'),
96+
description: t('wallet.error.notAllowed'),
9197
});
98+
return;
9299
}
93100
} catch (e: unknown) {
94101
console.error(e);
102+
const error = e as Error;
103+
104+
// Detect specific error types
105+
if (error.message?.includes('Freighter')) {
106+
setConnectionError('not-installed');
107+
} else {
108+
setConnectionError('generic');
109+
}
110+
95111
toast.error({
96112
title: t('toast.walletConnectionFailed.title'),
97-
description: t('toast.walletConnectionFailed.description'),
98-
});
113+
description: t('wallet.error.generic'),
114+
});
99115
} finally {
100116
setIsConnecting(false);
101117
}
@@ -105,6 +121,33 @@ const WalletConnect: React.FC<WalletConnectProps> = ({ walletAddress, onConnect,
105121
return `${addr.substring(0, 5)}...${addr.substring(addr.length - 4)}`;
106122
};
107123

124+
const getErrorDescription = (): string => {
125+
switch (connectionError) {
126+
case 'not-installed':
127+
return t('wallet.error.notInstalled');
128+
case 'not-allowed':
129+
return t('wallet.error.notAllowed');
130+
case 'no-address':
131+
return t('wallet.error.noAddress');
132+
case 'generic':
133+
return t('wallet.error.generic');
134+
default:
135+
return '';
136+
}
137+
};
138+
139+
const getStatusTooltip = (): string => {
140+
if (walletAddress) {
141+
return t('wallet.tooltip.connectedStatus');
142+
} else if (isConnecting) {
143+
return t('wallet.tooltip.connectingStatus');
144+
} else if (connectionError) {
145+
return getErrorDescription();
146+
} else {
147+
return t('wallet.tooltip.disconnectedStatus');
148+
}
149+
};
150+
108151
if (walletAddress) {
109152
return (
110153
<div className="wallet-status flex items-center gap-md">
@@ -150,6 +193,7 @@ const WalletConnect: React.FC<WalletConnectProps> = ({ walletAddress, onConnect,
150193
className="btn btn-outline"
151194
style={{ padding: '8px', borderRadius: '50%' }}
152195
onClick={() => {
196+
setConnectionError(null);
153197
onDisconnect();
154198
toast.info({
155199
title: t('toast.walletDisconnected.title'),
@@ -167,16 +211,87 @@ const WalletConnect: React.FC<WalletConnectProps> = ({ walletAddress, onConnect,
167211
return (
168212
<div style={{ position: 'relative' }}>
169213
<button
170-
className="btn btn-primary animate-glow"
214+
ref={buttonRef}
215+
className={`btn ${connectionError ? 'btn-error' : 'btn-primary'} ${isConnecting ? 'animate-glow' : ''}`}
171216
onClick={handleConnect}
172217
disabled={isConnecting}
173218
aria-busy={isConnecting}
219+
onMouseEnter={() => setShowTooltip(true)}
220+
onMouseLeave={() => setShowTooltip(false)}
221+
onFocus={() => setShowTooltip(true)}
222+
onBlur={() => setShowTooltip(false)}
223+
title={getStatusTooltip()}
224+
style={{
225+
display: 'flex',
226+
alignItems: 'center',
227+
gap: '8px',
228+
position: 'relative',
229+
}}
174230
>
175-
{isConnecting ? <Loader2 size={18} className="spin" style={{ animation: 'spin 1s linear infinite' }} /> : <Wallet size={18} />}
176-
{isConnecting ? t('wallet.connecting') : t('wallet.connectFreighter')}
231+
{isConnecting ? (
232+
<Loader2 size={18} className="spin" style={{ animation: 'spin 1s linear infinite' }} />
233+
) : connectionError ? (
234+
<AlertCircle size={18} />
235+
) : (
236+
<Wallet size={18} />
237+
)}
238+
<span>{isConnecting ? t('wallet.connecting') : t('wallet.connectFreighter')}</span>
177239
</button>
240+
241+
{/* Tooltip */}
242+
{showTooltip && (
243+
<div
244+
style={{
245+
position: 'absolute',
246+
bottom: '100%',
247+
left: '50%',
248+
transform: 'translateX(-50%)',
249+
marginBottom: '8px',
250+
padding: '8px 12px',
251+
backgroundColor: 'var(--surface-secondary)',
252+
border: connectionError ? '1px solid var(--accent-red-dim)' : '1px solid var(--accent-cyan-dim)',
253+
borderRadius: '4px',
254+
fontSize: '0.75rem',
255+
color: connectionError ? 'var(--accent-red)' : 'var(--text-secondary)',
256+
whiteSpace: 'nowrap',
257+
zIndex: 1000,
258+
boxShadow: connectionError
259+
? '0 0 12px rgba(255, 80, 100, 0.15)'
260+
: '0 0 12px rgba(0, 240, 255, 0.15)',
261+
pointerEvents: 'none',
262+
}}
263+
>
264+
{getStatusTooltip()}
265+
<div
266+
style={{
267+
position: 'absolute',
268+
top: '100%',
269+
left: '50%',
270+
transform: 'translateX(-50%)',
271+
width: '0',
272+
height: '0',
273+
borderLeft: '4px solid transparent',
274+
borderRight: '4px solid transparent',
275+
borderTop: connectionError
276+
? '4px solid var(--accent-red-dim)'
277+
: '4px solid var(--accent-cyan-dim)',
278+
}}
279+
/>
280+
</div>
281+
)}
282+
178283
<style>{`
179284
@keyframes spin { 100% { transform: rotate(360deg); } }
285+
.btn-error {
286+
background-color: rgba(255, 80, 100, 0.1);
287+
border-color: var(--accent-red-dim);
288+
color: var(--accent-red);
289+
}
290+
.btn-error:hover:not(:disabled) {
291+
background-color: rgba(255, 80, 100, 0.2);
292+
border-color: var(--accent-red);
293+
box-shadow: 0 0 12px rgba(255, 80, 100, 0.3);
294+
}
180295
`}</style>
181296
</div>
182297
);

0 commit comments

Comments
 (0)