Labels: bug, enhancement, good-first-issue, ui/ux, bounty:$100
Summary
On /faucet, clicking Claim on a single token card triggers a global loading state: every card's Claim button and the big "Claim Test Tokens" button all show as pending/disabled simultaneously, even though only one token is being claimed. This makes the page feel broken and prevents the user from understanding what is actually happening.
Each card must own its own claim lifecycle (idle → pending → success/error), independent of the other cards and of the bulk-claim button.
Root cause
useClaim exposes a single boolean isPending for the whole hook:
// apps/web/src/features/faucet/hooks/useClaim.tsx
const [isPending, setIsPending] = useState(false)
const claim = useCallback(async (tokenIds = FAUCET_TOKENS.map(t => t.contractId)) => {
setIsPending(true)
...
}, [...])
return { claim, isPending }
faucet-page.tsx then fans that one flag out to every card and to the bulk button:
const { claim, isPending } = useClaim()
const claimDisabled = !isConnected || isPending || mismatch // isPending = ANY claim in flight
...
{FAUCET_TOKENS.map((token) => (
<TokenCard ... isPending={isPending} isDisabled={claimDisabled}
onClaim={(t) => claim([t.contractId])} />
))}
...
<Button disabled={claimDisabled} onClick={() => claim()}> {/* bulk */}
{isPending ? "Claiming…" : "Claim Test Tokens"}
</Button>
So a single-token claim and the bulk claim are indistinguishable to the UI.
Acceptance criteria
Suggested approach (non-binding)
Track which token(s) are in flight instead of a single boolean — e.g. useClaim returns pendingTokens: Set<string> (or a pendingId and an isBulkPending), and the page derives each card's isPending from membership. Keep a clear distinction between a single-token claim and the bulk claim() with no args. A small state machine or a Record<contractId, status> is also fine. Whatever the shape, the card components should remain dumb and receive their own isPending / status as props (TokenCard already takes per-card props — extend that pattern; the local state lives in the page or hook, not scattered into each card via separate hook instances).
Avoid the trap of calling useClaim() once per card to "isolate" state — that creates N independent toast/submit pipelines and N wallet-kit references. Prefer one hook that tracks per-token status.
Out of scope
- Changing the underlying
claim_many contract call or the cooldown logic.
- Faucet visual redesign (cards already look good) — this is a state/UX correctness fix.
Pointers
Definition of done
- Single-card and bulk claims are visually independent;
bun run typecheck and bun run lint pass; manually verified on testnet with a connected wallet (claim one token, confirm other cards/bulk button stay idle; claim bulk, confirm cards reflect it). Before/after screen recording or GIF in the PR.
Labels:
bug,enhancement,good-first-issue,ui/ux,bounty:$100Summary
On
/faucet, clicking Claim on a single token card triggers a global loading state: every card's Claim button and the big "Claim Test Tokens" button all show as pending/disabled simultaneously, even though only one token is being claimed. This makes the page feel broken and prevents the user from understanding what is actually happening.Each card must own its own claim lifecycle (idle → pending → success/error), independent of the other cards and of the bulk-claim button.
Root cause
useClaimexposes a single booleanisPendingfor the whole hook:faucet-page.tsxthen fans that one flag out to every card and to the bulk button:So a single-token claim and the bulk claim are indistinguishable to the UI.
Acceptance criteria
!isConnected/mismatchdisables still apply to all).ClaimTooSooncooldown messaging.invalidateQueries(queryKeys.faucet.data(address))behaviour is preserved) and the just-claimed card reflects the new balance / updated "Last claim ledger".Suggested approach (non-binding)
Track which token(s) are in flight instead of a single boolean — e.g.
useClaimreturnspendingTokens: Set<string>(or apendingIdand anisBulkPending), and the page derives each card'sisPendingfrom membership. Keep a clear distinction between a single-token claim and the bulkclaim()with no args. A small state machine or aRecord<contractId, status>is also fine. Whatever the shape, the card components should remain dumb and receive their ownisPending/statusas props (TokenCardalready takes per-card props — extend that pattern; the local state lives in the page or hook, not scattered into each card via separate hook instances).Out of scope
claim_manycontract call or the cooldown logic.Pointers
faucet-page.tsx(TokenCard,FaucetPage)useClaim.tsxuseFaucetData,data/tokens.tsDefinition of done
bun run typecheckandbun run lintpass; manually verified on testnet with a connected wallet (claim one token, confirm other cards/bulk button stay idle; claim bulk, confirm cards reflect it). Before/after screen recording or GIF in the PR.