Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
82b5b29
fix: remove premature refetchBalance from onFinish callback
Mosas2000 Mar 14, 2026
2cd2462
fix: trigger refetchBalance when tx is confirmed on-chain
Mosas2000 Mar 14, 2026
916c7c7
fix: also refetch balance on transaction failure
Mosas2000 Mar 14, 2026
07b2228
feat: show pending confirmation indicator on balance
Mosas2000 Mar 14, 2026
e3fb32b
feat(useBalance): track lastFetched timestamp
Mosas2000 Mar 14, 2026
332a67c
feat(useBalance): set lastFetched on successful fetch
Mosas2000 Mar 14, 2026
04321bd
feat(useBalance): expose lastFetched in hook return value
Mosas2000 Mar 14, 2026
32d81b0
docs(useBalance): update JSDoc return type with lastFetched
Mosas2000 Mar 14, 2026
0fcf207
refactor(useBalance): import useRef for retry tracking
Mosas2000 Mar 14, 2026
c02c1d3
feat(useBalance): add retry constants
Mosas2000 Mar 14, 2026
74e9d1a
feat(useBalance): add retry logic for transient fetch failures
Mosas2000 Mar 14, 2026
0a738b2
test(tx-status): add data-status attribute for test assertions
Mosas2000 Mar 14, 2026
b3b2b49
a11y(tx-status): add aria-busy while transaction is pending
Mosas2000 Mar 14, 2026
855fd18
feat(tx-status): show poll count during pending state
Mosas2000 Mar 14, 2026
0a98f0c
feat(tx-status): add block time estimate during pending
Mosas2000 Mar 14, 2026
3747471
docs(SendTip): add JSDoc to handleTxConfirmed
Mosas2000 Mar 14, 2026
6c05db0
docs(SendTip): add JSDoc to handleTxFailed
Mosas2000 Mar 14, 2026
961ec93
a11y(SendTip): add aria-live to pending transaction section
Mosas2000 Mar 14, 2026
a926a01
docs(SendTip): annotate MIN_TIP_STX constant
Mosas2000 Mar 14, 2026
81fc490
docs(SendTip): annotate MAX_TIP_STX constant
Mosas2000 Mar 14, 2026
229fce9
docs(SendTip): annotate COOLDOWN_SECONDS constant
Mosas2000 Mar 14, 2026
340a9ef
a11y(SendTip): link amount input to its error via aria-describedby
Mosas2000 Mar 14, 2026
6a18dc2
a11y(SendTip): add id and role=alert to amount error message
Mosas2000 Mar 14, 2026
677aff9
a11y(SendTip): link recipient input to its error via aria-describedby
Mosas2000 Mar 14, 2026
8c2e5e6
a11y(SendTip): add id and role=alert to recipient error message
Mosas2000 Mar 14, 2026
011730d
a11y(SendTip): add aria-disabled to submit button
Mosas2000 Mar 14, 2026
a4bb141
test(SendTip): add data-testid to balance section
Mosas2000 Mar 14, 2026
32aef89
test(SendTip): add data-testid to fee preview section
Mosas2000 Mar 14, 2026
b468a42
docs(useBalance): update JSDoc with retry mention
Mosas2000 Mar 14, 2026
00a9863
docs(useBalance): document retry and manual refetch behavior
Mosas2000 Mar 14, 2026
ae10444
style: ensure trailing newlines in modified files
Mosas2000 Mar 14, 2026
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
33 changes: 22 additions & 11 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import { analytics } from '../lib/analytics';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';

const MIN_TIP_STX = 0.001;
const MAX_TIP_STX = 10000;
const COOLDOWN_SECONDS = 10;
const MIN_TIP_STX = 0.001; // minimum tip in STX
const MAX_TIP_STX = 10000; // maximum tip in STX
const COOLDOWN_SECONDS = 10; // seconds between allowed tips

const TIP_CATEGORIES = [
{ id: 0, label: 'General' },
Expand Down Expand Up @@ -182,7 +182,6 @@ export default function SendTip({ addToast }) {
setMessage('');
setCategory(0);
notifyTipSent();
refetchBalance();
startCooldown();
analytics.trackTipConfirmed();
addToast('Tip sent! Tx: ' + data.txId, 'success');
Expand All @@ -208,13 +207,17 @@ export default function SendTip({ addToast }) {
}
};

/** Called by TxStatus when the transaction reaches 'success' status. */
const handleTxConfirmed = useCallback(() => {
refetchBalance();
addToast('Tip confirmed on-chain!', 'success');
}, [addToast]);
}, [addToast, refetchBalance]);

/** Called by TxStatus when the transaction is aborted on-chain. */
const handleTxFailed = useCallback((reason) => {
refetchBalance();
addToast(`Transaction failed: ${reason}`, 'error');
}, [addToast]);
}, [addToast, refetchBalance]);

return (
<div className="max-w-md mx-auto">
Expand All @@ -223,14 +226,19 @@ export default function SendTip({ addToast }) {

{/* Balance */}
{senderAddress && (
<div className="mb-5 flex items-center justify-between bg-gray-50 dark:bg-gray-800 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-700">
<div data-testid="balance-section" className="mb-5 flex items-center justify-between bg-gray-50 dark:bg-gray-800 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-700">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Your Balance</p>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{balanceLoading ? 'Loading...' : balanceSTX !== null
? formatBalance(balance)
: 'Unavailable'}
</p>
{pendingTx && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Pending confirmation
</p>
)}
</div>
<button onClick={refetchBalance} disabled={balanceLoading}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50" title="Refresh balance" aria-label="Refresh balance">
Expand All @@ -246,9 +254,10 @@ export default function SendTip({ addToast }) {
<div>
<label htmlFor="tip-recipient" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Recipient Address</label>
<input id="tip-recipient" type="text" value={recipient} onChange={(e) => handleRecipientChange(e.target.value)}
aria-describedby={recipientError ? "tip-recipient-error" : undefined}
className={`w-full px-4 py-2.5 border rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all bg-white dark:bg-gray-800 dark:text-white ${recipientError ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-700'}`}
placeholder="SP2..." />
{recipientError && <p className="mt-1 text-xs text-red-500">{recipientError}</p>}
{recipientError && <p id="tip-recipient-error" className="mt-1 text-xs text-red-500" role="alert">{recipientError}</p>}
{blockedWarning && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
This recipient has blocked you. The transaction will likely fail on-chain.
Expand All @@ -260,9 +269,10 @@ export default function SendTip({ addToast }) {
<div>
<label htmlFor="tip-amount" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Amount (STX)</label>
<input id="tip-amount" type="number" value={amount} onChange={(e) => handleAmountChange(e.target.value)}
aria-describedby={amountError ? "tip-amount-error" : undefined}
className={`w-full px-4 py-2.5 border rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all bg-white dark:bg-gray-800 dark:text-white ${amountError ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-700'}`}
placeholder="0.5" step="0.001" min={MIN_TIP_STX} max={MAX_TIP_STX} />
{amountError && <p className="mt-1 text-xs text-red-500">{amountError}</p>}
{amountError && <p id="tip-amount-error" className="mt-1 text-xs text-red-500" role="alert">{amountError}</p>}
</div>

{/* Message */}
Expand All @@ -289,7 +299,7 @@ export default function SendTip({ addToast }) {

{/* Breakdown with fee preview and post-condition ceiling */}
{amount && parseFloat(amount) > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 border border-gray-100 dark:border-gray-700 text-sm">
<div data-testid="fee-preview" className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 border border-gray-100 dark:border-gray-700 text-sm">
<p className="font-semibold text-gray-700 dark:text-gray-200 mb-2">Fee Preview</p>
<div className="space-y-1 text-gray-600 dark:text-gray-400">
<div className="flex justify-between">
Expand Down Expand Up @@ -325,6 +335,7 @@ export default function SendTip({ addToast }) {

{/* Submit */}
<button onClick={validateAndConfirm} disabled={loading || cooldown > 0}
aria-disabled={loading || cooldown > 0}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-black font-bold py-3 px-4 rounded-xl shadow-sm hover:shadow-md transition-all active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed">
{loading ? (
<span className="flex items-center justify-center gap-2">
Expand All @@ -340,7 +351,7 @@ export default function SendTip({ addToast }) {

{/* Pending TX */}
{pendingTx && (
<div data-testid="pending-tx" className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-100 dark:border-green-800">
<div data-testid="pending-tx" aria-live="polite" className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-100 dark:border-green-800">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
Sent {pendingTx.amount} STX to <span className="font-mono text-xs">{pendingTx.recipient.slice(0, 8)}...{pendingTx.recipient.slice(-4)}</span>
</p>
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/ui/tx-status.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ export default function TxStatus({ txId, onConfirmed, onFailed }) {
const explorerUrl = `${EXPLORER_BASE_URL}/${txId}?chain=mainnet`;

return (
<div data-testid="tx-status" className={`mt-4 p-4 rounded-xl border ${config.color}`} role="status" aria-live="polite">
<div data-testid="tx-status" data-status={status} aria-busy={status === 'pending'} className={`mt-4 p-4 rounded-xl border ${config.color}`} role="status" aria-live="polite">
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${config.dot}`} aria-hidden="true" />
<span className="text-sm font-medium">{config.label}</span>
{status === 'pending' && pollCount > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 ml-2">({pollCount}/{MAX_POLLS})</span>
)}
</div>
<div className="mt-2 flex items-center gap-2">
<a
Expand All @@ -121,6 +124,11 @@ export default function TxStatus({ txId, onConfirmed, onFailed }) {
Still waiting. Check the explorer for the latest status.
</p>
)}
{status === 'pending' && pollCount > 0 && pollCount < MAX_POLLS && (
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
Stacks blocks typically take 10-30 minutes.
</p>
)}
</div>
);
}
61 changes: 41 additions & 20 deletions frontend/src/hooks/useBalance.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { STACKS_API_BASE } from '../config/contracts';
import { microToStx } from '../lib/balance-utils';

const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1500;

/**
* Fetch and track the STX balance for a Stacks address.
* Includes automatic retry on transient failures.
*
* The balance is stored as the raw string returned by the Stacks API
* (`/extended/v1/address/:addr/stx`), representing micro-STX. Consumers
* should use the balance-utils helpers (`microToStx`, `formatBalance`) to
* convert for display rather than dividing by a magic number.
*
* On error the hook retries up to MAX_RETRIES times before setting the
* error state. Call refetch() to manually retry after a failure.
*
* @param {string|null} address - Stacks principal to query. Pass null to skip.
* @returns {{ balance: string|null, balanceStx: number|null, loading: boolean, error: string|null, refetch: () => Promise<void> }}
* @returns {{ balance: string|null, balanceStx: number|null, loading: boolean, error: string|null, lastFetched: number|null, refetch: () => Promise<void> }}
*/
export function useBalance(address) {
const [balance, setBalance] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [lastFetched, setLastFetched] = useState(null);

const retryCount = useRef(0);

const fetchBalance = useCallback(async () => {
if (!address) {
Expand All @@ -26,29 +36,40 @@ export function useBalance(address) {

setLoading(true);
setError(null);
retryCount.current = 0;

try {
const res = await fetch(
`${STACKS_API_BASE}/extended/v1/address/${address}/stx`
);
const attempt = async () => {
try {
const res = await fetch(
`${STACKS_API_BASE}/extended/v1/address/${address}/stx`
);

if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}
if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}

const data = await res.json();

const data = await res.json();
if (typeof data?.balance !== 'string' && typeof data?.balance !== 'number') {
throw new Error('Unexpected balance format in API response');
}

if (typeof data?.balance !== 'string' && typeof data?.balance !== 'number') {
throw new Error('Unexpected balance format in API response');
setBalance(String(data.balance));
setLastFetched(Date.now());
setLoading(false);
} catch (err) {
if (retryCount.current < MAX_RETRIES) {
retryCount.current += 1;
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
return attempt();
}
console.error('Failed to fetch balance:', err.message);
setError(err.message);
setLoading(false);
}
};

setBalance(String(data.balance));
} catch (err) {
console.error('Failed to fetch balance:', err.message);
setError(err.message);
} finally {
setLoading(false);
}
await attempt();
}, [address]);

useEffect(() => {
Expand All @@ -58,5 +79,5 @@ export function useBalance(address) {
/** Derived STX balance — memoised to avoid re-computing on every render. */
const balanceStx = useMemo(() => microToStx(balance), [balance]);

return { balance, balanceStx, loading, error, refetch: fetchBalance };
return { balance, balanceStx, loading, error, lastFetched, refetch: fetchBalance };
}
Loading