Skip to content

Commit 331b281

Browse files
authored
Merge pull request #65 from spiffamani/feat/balance-warning
feat(wallet): add insufficient balance warning before tx submission
2 parents 27dcf5c + d414332 commit 331b281

3 files changed

Lines changed: 246 additions & 1 deletion

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useWalletStore } from '@/store/walletStore';
5+
import { useWalletBalance } from '@/hooks/useWalletBalance';
6+
7+
// ─── Types ────────────────────────────────────────────────────────────────────
8+
9+
export interface BalanceCheckProps {
10+
/** Total XLM required for this transaction (escrow amount + estimated fee). */
11+
requiredAmount: number;
12+
/** Called when the user confirms and submits the transaction. */
13+
onSubmit: () => void;
14+
/** Optional label for the submit button. Defaults to "Confirm & Lock Escrow". */
15+
submitLabel?: string;
16+
/** Whether the parent form has additional validation errors that block submit. */
17+
isFormInvalid?: boolean;
18+
}
19+
20+
// ─── Component ───────────────────────────────────────────────────────────────
21+
22+
/**
23+
* BalanceCheck — queries wallet balance before an escrow lock and renders a
24+
* warning when the balance is insufficient. The submit button is disabled
25+
* only when the balance is *definitively* too low.
26+
*
27+
* Follows the Component → Hook → Service layered architecture:
28+
* BalanceCheck → useWalletBalance → walletService
29+
*/
30+
export function BalanceCheck({
31+
requiredAmount,
32+
onSubmit,
33+
submitLabel = 'Confirm & Lock Escrow',
34+
isFormInvalid = false,
35+
}: BalanceCheckProps) {
36+
const address = useWalletStore((state) => state.address);
37+
const { balance, isLoading, error, isInsufficient, refresh } =
38+
useWalletBalance(address);
39+
40+
const insufficient = isInsufficient(requiredAmount);
41+
42+
// The button is disabled only when the balance is definitively insufficient
43+
// OR when the parent form has other blocking validation errors.
44+
const isSubmitDisabled = insufficient || isFormInvalid;
45+
46+
return (
47+
<div className="space-y-3">
48+
{/* ── Balance display ───────────────────────────────────────────────── */}
49+
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
50+
<div className="flex items-center justify-between">
51+
<span className="text-sm font-medium text-gray-600">Wallet Balance</span>
52+
53+
{isLoading ? (
54+
<span className="text-sm text-gray-400 animate-pulse">Fetching…</span>
55+
) : error ? (
56+
<button
57+
type="button"
58+
onClick={refresh}
59+
className="text-sm text-blue-600 hover:underline"
60+
>
61+
Retry
62+
</button>
63+
) : balance !== null ? (
64+
<span
65+
className={`text-sm font-semibold ${
66+
insufficient ? 'text-red-600' : 'text-green-600'
67+
}`}
68+
>
69+
{balance.toFixed(7)} XLM
70+
</span>
71+
) : null}
72+
</div>
73+
74+
<div className="mt-1 flex items-center justify-between">
75+
<span className="text-sm font-medium text-gray-600">Required</span>
76+
<span className="text-sm font-semibold text-gray-800">
77+
{requiredAmount.toFixed(7)} XLM
78+
</span>
79+
</div>
80+
</div>
81+
82+
{/* ── Insufficient balance warning ──────────────────────────────────── */}
83+
{insufficient && (
84+
<div
85+
role="alert"
86+
aria-live="polite"
87+
className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700"
88+
>
89+
{/* Warning icon */}
90+
<svg
91+
xmlns="http://www.w3.org/2000/svg"
92+
className="mt-0.5 h-5 w-5 shrink-0 text-red-500"
93+
viewBox="0 0 20 20"
94+
fill="currentColor"
95+
aria-hidden="true"
96+
>
97+
<path
98+
fillRule="evenodd"
99+
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
100+
clipRule="evenodd"
101+
/>
102+
</svg>
103+
104+
<div className="text-sm">
105+
<p className="font-semibold">Insufficient balance</p>
106+
<p className="mt-0.5">
107+
You need at least{' '}
108+
<span className="font-medium">{requiredAmount.toFixed(7)} XLM</span> to
109+
complete this transaction. Your current balance is{' '}
110+
<span className="font-medium">{balance!.toFixed(7)} XLM</span>.
111+
</p>
112+
<p className="mt-1 text-xs text-red-600">
113+
Please top up your wallet before proceeding.
114+
</p>
115+
</div>
116+
</div>
117+
)}
118+
119+
{/* ── Balance fetch error (non-blocking) ────────────────────────────── */}
120+
{error && !isLoading && (
121+
<p className="text-xs text-amber-600">
122+
⚠️ Could not verify balance — you may still proceed, but ensure you have
123+
sufficient XLM.
124+
</p>
125+
)}
126+
127+
{/* ── Submit button ─────────────────────────────────────────────────── */}
128+
<button
129+
type="button"
130+
onClick={onSubmit}
131+
disabled={isSubmitDisabled}
132+
aria-disabled={isSubmitDisabled}
133+
className={`w-full rounded-lg px-4 py-3 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 ${
134+
isSubmitDisabled
135+
? 'cursor-not-allowed bg-gray-200 text-gray-400'
136+
: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500'
137+
}`}
138+
>
139+
{isLoading ? 'Checking balance…' : submitLabel}
140+
</button>
141+
</div>
142+
);
143+
}
144+
145+
export default BalanceCheck;

hooks/useWalletBalance.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
import { walletService } from '@/services/walletService';
3+
4+
export interface UseWalletBalanceResult {
5+
/** Current XLM balance fetched from the backend. Null while loading or on error. */
6+
balance: number | null;
7+
/** True while the balance request is in-flight. */
8+
isLoading: boolean;
9+
/** Error message if the request failed, otherwise null. */
10+
error: string | null;
11+
/**
12+
* Returns true when the balance is definitively known to be insufficient.
13+
* False when balance is null (still loading / unknown) — never blocks on uncertainty.
14+
*/
15+
isInsufficient: (requiredAmount: number) => boolean;
16+
/** Manually re-fetch the balance. */
17+
refresh: () => void;
18+
}
19+
20+
/**
21+
* useWalletBalance — fetches the XLM balance for the connected wallet.
22+
*
23+
* Follows the Component → Hook → Service pattern:
24+
* BalanceCheck (component) → useWalletBalance (hook) → walletService (service)
25+
*
26+
* The submit button must only be disabled when the balance is *definitively*
27+
* lower than required — never when the balance is still loading or unknown.
28+
*/
29+
export function useWalletBalance(address: string | null): UseWalletBalanceResult {
30+
const [balance, setBalance] = useState<number | null>(null);
31+
const [isLoading, setIsLoading] = useState(false);
32+
const [error, setError] = useState<string | null>(null);
33+
34+
const fetchBalance = useCallback(async () => {
35+
if (!address) {
36+
setBalance(null);
37+
return;
38+
}
39+
40+
setIsLoading(true);
41+
setError(null);
42+
43+
try {
44+
const response = await walletService.getBalance(address);
45+
if (response.success) {
46+
setBalance(response.balance);
47+
} else {
48+
setError(response.message ?? 'Failed to fetch wallet balance');
49+
setBalance(null);
50+
}
51+
} catch (err: any) {
52+
setError(
53+
err.response?.data?.message ?? 'An error occurred while fetching your balance'
54+
);
55+
setBalance(null);
56+
} finally {
57+
setIsLoading(false);
58+
}
59+
}, [address]);
60+
61+
useEffect(() => {
62+
fetchBalance();
63+
}, [fetchBalance]);
64+
65+
const isInsufficient = useCallback(
66+
(requiredAmount: number): boolean => {
67+
// Only block when balance is definitively known and too low.
68+
// When balance is null (loading or error) we do NOT block — uncertainty ≠ insufficient.
69+
if (balance === null) return false;
70+
return balance < requiredAmount;
71+
},
72+
[balance]
73+
);
74+
75+
return {
76+
balance,
77+
isLoading,
78+
error,
79+
isInsufficient,
80+
refresh: fetchBalance,
81+
};
82+
}

services/walletService.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export interface DisconnectResponse {
77
message: string;
88
}
99

10+
export interface BalanceResponse {
11+
success: boolean;
12+
balance: number; // XLM balance as a number
13+
message?: string;
14+
}
15+
1016
/**
1117
* walletService — responsible for all wallet-related API communication.
1218
* The hook calls this; components never call this directly.
@@ -18,4 +24,16 @@ export const walletService = {
1824
);
1925
return data;
2026
},
21-
};
27+
28+
/**
29+
* Fetch the XLM balance for the given wallet address from the backend.
30+
* The backend is the single source of truth — no Stellar SDK calls in the browser.
31+
*/
32+
async getBalance(address: string): Promise<BalanceResponse> {
33+
const { data } = await axios.get<BalanceResponse>(
34+
`${API_BASE_URL}/api/wallet/balance`,
35+
{ params: { address } }
36+
);
37+
return data;
38+
},
39+
};

0 commit comments

Comments
 (0)