diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index 2a0a4c4..ab05eb8 100644 --- a/apps/web/src/features/faucet/components/faucet-page.tsx +++ b/apps/web/src/features/faucet/components/faucet-page.tsx @@ -1,5 +1,10 @@ import { Skeleton } from "@workspace/ui/components/skeleton" import { Button } from "@workspace/ui/components/button" +import { FAUCET_TOKENS } from "../data/tokens" +import { FAUCET_CONTRACT_ID } from "../lib/clients" +import { useFaucetData } from "../hooks/useFaucetData" +import { useClaim } from "../hooks/useClaim" +import type { FaucetTokenConfig } from "../data/tokens" import { Navbar } from "@/ui/Navbar" import { TokenIcon } from "@/shared/components/TokenIcon" import { formatToken } from "@/shared/lib/format" @@ -8,10 +13,6 @@ import { useWalletStore } from "@/features/wallet/store/wallet-store" import { useNetwork } from "@/features/wallet/hooks/useNetwork" import { NetworkMismatchBanner } from "@/features/wallet/components/NetworkMismatchBanner" import { ConnectButton } from "@/features/wallet/components/ConnectButton" -import { FAUCET_TOKENS, type FaucetTokenConfig } from "../data/tokens" -import { FAUCET_CONTRACT_ID } from "../lib/clients" -import { useFaucetData } from "../hooks/useFaucetData" -import { useClaim } from "../hooks/useClaim" // ── Token card ──────────────────────────────────────────────────────────────── @@ -48,14 +49,16 @@ function TokenCard({
-

{token.symbol}

+

+ {token.symbol} +

{token.name}

-

+

Your balance

{isLoading ? ( @@ -68,7 +71,7 @@ function TokenCard({
-

+

Claim amount

{isLoading ? ( @@ -82,7 +85,9 @@ function TokenCard({
-

{cooldownText}

+

+ {cooldownText} +

@@ -104,21 +116,24 @@ export function FaucetPage() { const isConnected = useWalletStore((state) => state.status === "connected") const { mismatch } = useNetwork() const { data, isLoading } = useFaucetData(address) - const { claim, isPending } = useClaim() + const { claim, isBulkPending, isTokenPending } = useClaim() const isTestnet = NETWORK.name === "testnet" - const claimDisabled = !isConnected || isPending || mismatch + const globalClaimDisabled = !isConnected || mismatch + const bulkClaimDisabled = globalClaimDisabled || isBulkPending return (
-
+
{/* Header */}
-

Testnet Faucet

+

+ Testnet Faucet +

Stellar Testnet @@ -148,8 +163,8 @@ export function FaucetPage() { lastClaimLedger={data?.lastClaimLedgers[token.symbol]} cooldownLedgers={data?.cooldownLedgers} isLoading={isLoading} - isPending={isPending} - isDisabled={claimDisabled} + isPending={isTokenPending(token.contractId)} + isDisabled={globalClaimDisabled} onClaim={(selectedToken) => claim([selectedToken.contractId])} /> ))} @@ -159,10 +174,12 @@ export function FaucetPage() {
-

Claim test tokens

+

+ Claim test tokens +

- Receive TUSDC, TWBTC, TETH, and TXLM in a single transaction. A cooldown - applies between claims. + Receive TUSDC, TWBTC, TETH, and TXLM in a single + transaction. A cooldown applies between claims.

@@ -174,17 +191,19 @@ export function FaucetPage() { {!isConnected ? (
-

Connect your wallet to claim test tokens.

+

+ Connect your wallet to claim test tokens. +

) : (
@@ -205,7 +225,7 @@ export function FaucetPage() { {/* Info panel */}
-

+

Contract addresses

@@ -216,8 +236,13 @@ export function FaucetPage() { id: token.contractId, })), ].map(({ label, id }) => ( -
-
{label}
+
+
+ {label} +
{id}
diff --git a/apps/web/src/features/faucet/hooks/useClaim.tsx b/apps/web/src/features/faucet/hooks/useClaim.tsx index ff46593..7d8e79f 100644 --- a/apps/web/src/features/faucet/hooks/useClaim.tsx +++ b/apps/web/src/features/faucet/hooks/useClaim.tsx @@ -1,13 +1,13 @@ -import { useCallback, useState } from "react" +import { useCallback, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { useQueryClient } from "@tanstack/react-query" +import { FAUCET_TOKENS } from "../data/tokens" +import { createFaucetClient } from "../lib/clients" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { walletKit } from "@/features/wallet/lib/wallet-kit" import { sendAndPoll } from "@/lib/tx-builder" import { explorerTxUrl } from "@/app/config/network" import { queryKeys } from "@/shared/lib/query-keys" -import { FAUCET_TOKENS } from "../data/tokens" -import { createFaucetClient } from "../lib/clients" import { parseSorobanError } from "@/lib/contracts" function isClaimTooSoonError(error: unknown): boolean { @@ -24,53 +24,121 @@ export function useClaim() { const address = useWalletStore((state) => state.address) const isConnected = useWalletStore((state) => state.status === "connected") const queryClient = useQueryClient() - const [isPending, setIsPending] = useState(false) + const [pendingTokenIds, setPendingTokenIds] = useState>( + () => new Set() + ) + const pendingTokenIdsRef = useRef>(new Set()) + const isBulkPendingRef = useRef(false) + const [isBulkPending, setIsBulkPending] = useState(false) + + const setBulkPending = useCallback((value: boolean) => { + isBulkPendingRef.current = value + setIsBulkPending(value) + }, []) + + const markTokensPending = useCallback((tokenIds: Array) => { + setPendingTokenIds((current) => { + const next = new Set(current) + tokenIds.forEach((tokenId) => next.add(tokenId)) + pendingTokenIdsRef.current = next + return next + }) + }, []) - const claim = useCallback(async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { - if (!address || !isConnected) return + const clearTokensPending = useCallback((tokenIds: Array) => { + setPendingTokenIds((current) => { + const next = new Set(current) + tokenIds.forEach((tokenId) => next.delete(tokenId)) + pendingTokenIdsRef.current = next + return next + }) + }, []) - setIsPending(true) - const toastId = toast.loading( - tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…", - ) + const claim = useCallback( + async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { + if (!address || !isConnected) return - try { - const faucet = createFaucetClient(address) - const tx = await faucet.claim_many({ - account: address, - tokens: tokenIds, - }) + const isBulkClaim = tokenIds.length !== 1 + const hasPendingToken = tokenIds.some((tokenId) => + pendingTokenIdsRef.current.has(tokenId) + ) + if (isBulkClaim ? isBulkPendingRef.current : hasPendingToken) return - const unsignedXdr = tx.toXDR() - const { signedTxXdr } = await walletKit.signTransaction(unsignedXdr) - const signedXdr = signedTxXdr - const { hash } = await sendAndPoll(signedXdr) + if (isBulkClaim) { + setBulkPending(true) + } + markTokensPending(tokenIds) + const toastId = toast.loading( + tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…" + ) - // Refresh balances after a successful claim - await queryClient.invalidateQueries({ queryKey: queryKeys.faucet.data(address) }) + try { + const faucet = createFaucetClient(address) + const tx = await faucet.claim_many({ + account: address, + tokens: tokenIds, + }) - toast.success("Test tokens claimed!", { - id: toastId, - description: ( - - View transaction → - - ), - }) - } catch (error) { - const message = isClaimTooSoonError(error) - ? "Cooldown active — please wait before claiming again." - : parseSorobanError(error) - toast.error(message, { id: toastId }) - } finally { - setIsPending(false) - } - }, [address, isConnected, queryClient]) + const unsignedXdr = tx.toXDR() + const { signedTxXdr } = await walletKit.signTransaction(unsignedXdr) + const signedXdr = signedTxXdr + const { hash } = await sendAndPoll(signedXdr) + + // Refresh balances after a successful claim + await queryClient.invalidateQueries({ + queryKey: queryKeys.faucet.data(address), + }) + + toast.success("Test tokens claimed!", { + id: toastId, + description: ( + + View transaction → + + ), + }) + } catch (error) { + const message = isClaimTooSoonError(error) + ? "Cooldown active — please wait before claiming again." + : parseSorobanError(error) + toast.error(message, { id: toastId }) + } finally { + if (isBulkClaim) { + setBulkPending(false) + } + clearTokensPending(tokenIds) + } + }, + [ + address, + clearTokensPending, + isConnected, + markTokensPending, + queryClient, + setBulkPending, + ] + ) + + const isPending = isBulkPending || pendingTokenIds.size > 0 + const isTokenPending = useCallback( + (tokenId: string) => pendingTokenIds.has(tokenId), + [pendingTokenIds] + ) + const pendingTokenIdList = useMemo( + () => Array.from(pendingTokenIds), + [pendingTokenIds] + ) - return { claim, isPending } + return { + claim, + isPending, + isBulkPending, + isTokenPending, + pendingTokenIds: pendingTokenIdList, + } }