Skip to content

Commit 292fc51

Browse files
authored
Merge pull request #167 from Mosas2000/feature/user-balance-display
Display user STX balance before sending tips
2 parents c6cb892 + 4d9753a commit 292fc51

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

frontend/src/components/SendTip.jsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { openContractCall } from '@stacks/connect';
33
import {
44
stringUtf8CV,
@@ -11,6 +11,7 @@ import { network, appDetails, userSession } from '../utils/stacks';
1111
import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts';
1212
import { toMicroSTX, formatSTX } from '../lib/utils';
1313
import { useTipContext } from '../context/TipContext';
14+
import { useBalance } from '../hooks/useBalance';
1415
import ConfirmDialog from './ui/confirm-dialog';
1516
import TxStatus from './ui/tx-status';
1617

@@ -30,6 +31,18 @@ export default function SendTip({ addToast }) {
3031
const [recipientError, setRecipientError] = useState('');
3132
const [amountError, setAmountError] = useState('');
3233

34+
const senderAddress = useMemo(() => {
35+
try {
36+
return userSession.loadUserData().profile.stxAddress.mainnet;
37+
} catch {
38+
return null;
39+
}
40+
}, []);
41+
42+
const { balance, loading: balanceLoading, refetch: refetchBalance } = useBalance(senderAddress);
43+
44+
const balanceSTX = balance !== null ? Number(balance) / 1_000_000 : null;
45+
3346
const isValidStacksAddress = (address) => {
3447
if (!address) return false;
3548
const trimmed = address.trim();
@@ -59,6 +72,8 @@ export default function SendTip({ addToast }) {
5972
setAmountError(`Minimum tip is ${MIN_TIP_STX} STX`);
6073
} else if (parsed > MAX_TIP_STX) {
6174
setAmountError(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`);
75+
} else if (balanceSTX !== null && parsed > balanceSTX) {
76+
setAmountError('Insufficient balance');
6277
} else {
6378
setAmountError('');
6479
}
@@ -97,6 +112,11 @@ export default function SendTip({ addToast }) {
97112
return;
98113
}
99114

115+
if (balanceSTX !== null && parsedAmount > balanceSTX) {
116+
addToast('Insufficient STX balance for this tip', 'warning');
117+
return;
118+
}
119+
100120
setShowConfirm(true);
101121
};
102122

@@ -139,6 +159,7 @@ export default function SendTip({ addToast }) {
139159
setAmount('');
140160
setMessage('');
141161
notifyTipSent();
162+
refetchBalance();
142163
addToast('Tip sent successfully! Transaction: ' + data.txId, 'success');
143164
},
144165
onCancel: () => {
@@ -160,6 +181,31 @@ export default function SendTip({ addToast }) {
160181
<div className="max-w-md mx-auto p-6 bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-100 dark:border-gray-800">
161182
<h2 className="text-2xl font-bold mb-6 text-gray-800 dark:text-gray-100">Send a Tip</h2>
162183

184+
{senderAddress && (
185+
<div className="mb-5 flex items-center justify-between bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-3 border border-gray-200 dark:border-gray-700">
186+
<div>
187+
<p className="text-xs text-gray-500 dark:text-gray-400">Your Balance</p>
188+
<p className="text-lg font-semibold text-gray-800 dark:text-gray-100">
189+
{balanceLoading
190+
? 'Loading...'
191+
: balanceSTX !== null
192+
? `${balanceSTX.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })} STX`
193+
: 'Unavailable'}
194+
</p>
195+
</div>
196+
<button
197+
onClick={refetchBalance}
198+
disabled={balanceLoading}
199+
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50"
200+
title="Refresh balance"
201+
>
202+
<svg className={`w-4 h-4 ${balanceLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
203+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
204+
</svg>
205+
</button>
206+
</div>
207+
)}
208+
163209
<div className="space-y-5">
164210
<div>
165211
<label className="block text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">

frontend/src/config/contracts.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,10 @@
33

44
export const CONTRACT_ADDRESS = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T';
55
export const CONTRACT_NAME = 'tipstream';
6+
7+
const NETWORK = import.meta.env.VITE_NETWORK || 'mainnet';
8+
export const STACKS_API_BASE = NETWORK === 'mainnet'
9+
? 'https://api.hiro.so'
10+
: NETWORK === 'testnet'
11+
? 'https://api.testnet.hiro.so'
12+
: 'http://localhost:3999';

frontend/src/hooks/useBalance.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
import { STACKS_API_BASE } from '../config/contracts';
3+
4+
export function useBalance(address) {
5+
const [balance, setBalance] = useState(null);
6+
const [loading, setLoading] = useState(false);
7+
const [error, setError] = useState(null);
8+
9+
const fetchBalance = useCallback(async () => {
10+
if (!address) {
11+
setBalance(null);
12+
return;
13+
}
14+
15+
setLoading(true);
16+
setError(null);
17+
18+
try {
19+
const res = await fetch(
20+
`${STACKS_API_BASE}/extended/v1/address/${address}/stx`
21+
);
22+
23+
if (!res.ok) {
24+
throw new Error(`API returned ${res.status}`);
25+
}
26+
27+
const data = await res.json();
28+
setBalance(BigInt(data.balance));
29+
} catch (err) {
30+
console.error('Failed to fetch balance:', err.message);
31+
setError(err.message);
32+
} finally {
33+
setLoading(false);
34+
}
35+
}, [address]);
36+
37+
useEffect(() => {
38+
fetchBalance();
39+
}, [fetchBalance]);
40+
41+
return { balance, loading, error, refetch: fetchBalance };
42+
}

0 commit comments

Comments
 (0)