diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index 2a0a4c4..0afea43 100644 --- a/apps/web/src/features/faucet/components/faucet-page.tsx +++ b/apps/web/src/features/faucet/components/faucet-page.tsx @@ -1,5 +1,11 @@ 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 type { TokenClaimState } from "../hooks/useClaim" import { Navbar } from "@/ui/Navbar" import { TokenIcon } from "@/shared/components/TokenIcon" import { formatToken } from "@/shared/lib/format" @@ -8,10 +14,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 ──────────────────────────────────────────────────────────────── @@ -23,6 +25,7 @@ type TokenCardProps = { cooldownLedgers: number | undefined isLoading: boolean isPending: boolean + claimState: TokenClaimState isDisabled: boolean onClaim: (token: FaucetTokenConfig) => void } @@ -35,6 +38,7 @@ function TokenCard({ cooldownLedgers, isLoading, isPending, + claimState, isDisabled, onClaim, }: TokenCardProps) { @@ -42,20 +46,26 @@ function TokenCard({ lastClaimLedger && cooldownLedgers ? `Last claim ledger ${lastClaimLedger.toLocaleString()}` : "No claim recorded" + const claimFeedback = + claimState.status === "success" || claimState.status === "error" + ? claimState.message + : null return (
-

{token.symbol}

+

+ {token.symbol} +

{token.name}

-

+

Your balance

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

+

Claim amount

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

{cooldownText}

+

+ {cooldownText} +

+ + {claimFeedback ? ( +

+ {claimFeedback} +

+ ) : null}
) } @@ -104,21 +135,32 @@ 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, + hasPendingTokens, + getTokenClaimState, + } = useClaim() const isTestnet = NETWORK.name === "testnet" - const claimDisabled = !isConnected || isPending || mismatch + const globalClaimDisabled = !isConnected || mismatch + // Bulk claims include every faucet token, so disable bulk while any token claim is in flight. + const bulkClaimDisabled = + globalClaimDisabled || isBulkPending || hasPendingTokens return (
-
+
{/* Header */}
-

Testnet Faucet

+

+ Testnet Faucet +

Stellar Testnet @@ -148,8 +190,9 @@ export function FaucetPage() { lastClaimLedger={data?.lastClaimLedgers[token.symbol]} cooldownLedgers={data?.cooldownLedgers} isLoading={isLoading} - isPending={isPending} - isDisabled={claimDisabled} + isPending={isTokenPending(token.contractId)} + claimState={getTokenClaimState(token.contractId)} + isDisabled={globalClaimDisabled} onClaim={(selectedToken) => claim([selectedToken.contractId])} /> ))} @@ -159,10 +202,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 +219,19 @@ export function FaucetPage() { {!isConnected ? (
-

Connect your wallet to claim test tokens.

+

+ Connect your wallet to claim test tokens. +

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

+

Contract addresses

@@ -216,8 +264,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..516872d 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, 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 { @@ -20,57 +20,162 @@ function isClaimTooSoonError(error: unknown): boolean { ) } +type ClaimStatus = "idle" | "pending" | "success" | "error" + +export type TokenClaimState = { + status: ClaimStatus + message?: string +} + +const IDLE_CLAIM_STATE: TokenClaimState = { status: "idle" } + 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 [tokenClaimStates, setTokenClaimStates] = useState< + Record + >({}) - const claim = useCallback(async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { - if (!address || !isConnected) return + const setBulkPending = useCallback((value: boolean) => { + isBulkPendingRef.current = value + setIsBulkPending(value) + }, []) - setIsPending(true) - const toastId = toast.loading( - tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…", - ) + const markTokensPending = useCallback((tokenIds: Array) => { + const next = new Set(pendingTokenIdsRef.current) + tokenIds.forEach((tokenId) => next.add(tokenId)) + pendingTokenIdsRef.current = next + setPendingTokenIds(next) + }, []) - try { - const faucet = createFaucetClient(address) - const tx = await faucet.claim_many({ - account: address, - tokens: tokenIds, - }) + const clearTokensPending = useCallback((tokenIds: Array) => { + const next = new Set(pendingTokenIdsRef.current) + tokenIds.forEach((tokenId) => next.delete(tokenId)) + pendingTokenIdsRef.current = next + setPendingTokenIds(next) + }, []) - 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 → - - ), + const setTokensClaimState = useCallback( + (tokenIds: Array, state: TokenClaimState) => { + setTokenClaimStates((current) => { + const next = { ...current } + tokenIds.forEach((tokenId) => { + next[tokenId] = state + }) + return next }) - } 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]) - - return { claim, isPending } + }, + [] + ) + + const getTokenClaimState = useCallback( + (tokenId: string) => tokenClaimStates[tokenId] ?? IDLE_CLAIM_STATE, + [tokenClaimStates] + ) + + const isTokenPending = useCallback( + (tokenId: string) => pendingTokenIds.has(tokenId), + [pendingTokenIds] + ) + + const hasPendingTokens = pendingTokenIds.size > 0 + + const claim = useCallback( + async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { + if (!address || !isConnected) return + + const isBulkClaim = tokenIds.length !== 1 + const hasPendingToken = tokenIds.some((tokenId) => + pendingTokenIdsRef.current.has(tokenId) + ) + if ( + isBulkClaim + ? isBulkPendingRef.current || hasPendingToken + : hasPendingToken + ) + return + + if (isBulkClaim) { + setBulkPending(true) + } + markTokensPending(tokenIds) + setTokensClaimState(tokenIds, { status: "pending" }) + const toastId = toast.loading( + tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…" + ) + + try { + const faucet = createFaucetClient(address) + const tx = await faucet.claim_many({ + account: address, + tokens: tokenIds, + }) + + 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), + }) + + setTokensClaimState(tokenIds, { + status: "success", + message: "Claim submitted. Balance refreshes shortly.", + }) + + 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) + + setTokensClaimState(tokenIds, { status: "error", message }) + toast.error(message, { id: toastId }) + } finally { + if (isBulkClaim) { + setBulkPending(false) + } + clearTokensPending(tokenIds) + } + }, + [ + address, + clearTokensPending, + isConnected, + markTokensPending, + queryClient, + setBulkPending, + setTokensClaimState, + ] + ) + + return { + claim, + isBulkPending, + isTokenPending, + hasPendingTokens, + getTokenClaimState, + } }