-
Notifications
You must be signed in to change notification settings - Fork 0
feat: update faucet #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: prod
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,94 +1,36 @@ | ||
| import { t } from "@lingui/core/macro"; | ||
| import { Trans } from "@lingui/react/macro"; | ||
| import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; | ||
| import { | ||
| CheckCircleIcon, | ||
| ClockIcon, | ||
| CurrencyDollarIcon, | ||
| DropIcon, | ||
| SpinnerGapIcon, | ||
| WalletIcon, | ||
| WarningCircleIcon, | ||
| } from "@phosphor-icons/react"; | ||
| import { useRef, useState } from "react"; | ||
| import { usePrivy } from "@privy-io/react-auth"; | ||
| import { useConnection } from "wagmi"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; | ||
| import { InfoRow } from "@/components/ui/info-row"; | ||
| import { cn } from "@/lib/cn"; | ||
| import { useFaucetClaim } from "@/lib/faucet/use-faucet-claim"; | ||
| import { useFaucetModalActions, useFaucetModalOpen } from "@/stores/use-global-modal-store"; | ||
|
|
||
| const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY; | ||
|
|
||
| interface StepProps { | ||
| label: string; | ||
| active: boolean; | ||
| done: boolean; | ||
| } | ||
|
|
||
| function Step({ label, active, done }: StepProps) { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| "flex items-center gap-2 text-3xs py-1", | ||
| done ? "text-market-up-600" : active ? "text-primary-default" : "text-text-500", | ||
| )} | ||
| > | ||
| {done ? ( | ||
| <CheckCircleIcon className="size-3.5" /> | ||
| ) : active ? ( | ||
| <SpinnerGapIcon className="size-3.5 animate-spin" /> | ||
| ) : ( | ||
| <div className="size-3.5 rounded-full border border-current opacity-40" /> | ||
| )} | ||
| <span>{label}</span> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function ClaimProgress({ status }: { status: string }) { | ||
| const steps = [ | ||
| { key: "verifying-captcha", label: t`Verifying captcha` }, | ||
| { key: "verifying-balance", label: t`Checking balance` }, | ||
| { key: "claiming", label: t`Claiming USDH` }, | ||
| ]; | ||
| const activeIdx = steps.findIndex((s) => s.key === status); | ||
|
|
||
| return ( | ||
| <div className="space-y-0.5"> | ||
| {steps.map((step, i) => ( | ||
| <Step key={step.key} label={step.label} active={i === activeIdx} done={i < activeIdx} /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function FaucetModal() { | ||
| const open = useFaucetModalOpen(); | ||
| const { close } = useFaucetModalActions(); | ||
| const { address } = useConnection(); | ||
| const { authenticated, getAccessToken } = usePrivy(); | ||
| const { status, error, result, claim, reset } = useFaucetClaim(); | ||
| const [turnstileToken, setTurnstileToken] = useState<string | null>(null); | ||
| const turnstileRef = useRef<TurnstileInstance>(null); | ||
|
|
||
| const isProcessing = status === "verifying-captcha" || status === "verifying-balance" || status === "claiming"; | ||
|
|
||
| function handleClose() { | ||
| reset(); | ||
| setTurnstileToken(null); | ||
| close(); | ||
| } | ||
|
|
||
| function handleClaim() { | ||
| if (!turnstileToken || !address) return; | ||
| claim(turnstileToken, address); | ||
| } | ||
|
|
||
| function handleRetry() { | ||
| reset(); | ||
| setTurnstileToken(null); | ||
| turnstileRef.current?.reset(); | ||
| if (!address) return; | ||
| claim(address, getAccessToken); | ||
| } | ||
|
|
||
| if (status === "success") { | ||
|
|
@@ -145,7 +87,7 @@ export function FaucetModal() { | |
| <Button variant="outlined" onClick={handleClose} className="flex-1"> | ||
| <Trans>Cancel</Trans> | ||
| </Button> | ||
| <Button onClick={handleRetry} className="flex-1"> | ||
| <Button onClick={reset} className="flex-1"> | ||
| <Trans>Retry</Trans> | ||
| </Button> | ||
| </div> | ||
|
|
@@ -155,7 +97,7 @@ export function FaucetModal() { | |
| ); | ||
| } | ||
|
|
||
| if (isProcessing) { | ||
| if (status === "claiming") { | ||
| return ( | ||
| <Dialog open onOpenChange={() => {}}> | ||
| <DialogContent className="sm:max-w-md" showCloseButton={false}> | ||
|
|
@@ -171,7 +113,9 @@ export function FaucetModal() { | |
| <SpinnerGapIcon className="size-7 animate-spin text-primary-default" /> | ||
| </div> | ||
| </div> | ||
| <ClaimProgress status={status} /> | ||
| <p className="text-3xs text-primary-default"> | ||
| <Trans>Claiming USDH...</Trans> | ||
| </p> | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
|
|
@@ -214,20 +158,9 @@ export function FaucetModal() { | |
| <Trans>Amount</Trans> | ||
| </> | ||
| } | ||
| value="1,000 USDH" | ||
| value="50 USDH" | ||
| valueClassName="font-medium" | ||
| /> | ||
| <InfoRow | ||
| className="p-0" | ||
| labelClassName="flex items-center gap-1.5 text-text-950" | ||
| label={ | ||
| <> | ||
| <CurrencyDollarIcon className="size-3" /> | ||
| <Trans>Requirement</Trans> | ||
| </> | ||
| } | ||
| value={t`$5+ USDC balance`} | ||
| /> | ||
| <InfoRow | ||
| className="p-0" | ||
| labelClassName="flex items-center gap-1.5 text-text-950" | ||
|
|
@@ -241,20 +174,9 @@ export function FaucetModal() { | |
| /> | ||
| </div> | ||
|
|
||
| <div className="flex justify-center"> | ||
| <Turnstile | ||
| ref={turnstileRef} | ||
| siteKey={TURNSTILE_SITE_KEY} | ||
| options={{ theme: "dark", size: "normal" }} | ||
| onSuccess={setTurnstileToken} | ||
| onExpire={() => setTurnstileToken(null)} | ||
| onError={() => setTurnstileToken(null)} | ||
| /> | ||
| </div> | ||
|
|
||
| <Button variant="contained" onClick={handleClaim} disabled={!turnstileToken} className="w-full"> | ||
| <Button variant="contained" onClick={handleClaim} disabled={!authenticated} className="w-full"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 'Claim' button is disabled when the user is not authenticated via Privy ( |
||
| <DropIcon className="size-4" /> | ||
| <Trans>Claim 1,000 USDH</Trans> | ||
| <Trans>Claim 50 USDH</Trans> | ||
| </Button> | ||
|
Comment on lines
+177
to
180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of disabling the button when the user is not authenticated, it's better to provide a 'Login to Claim' action. This improves the user experience by allowing them to complete the authentication flow without leaving the modal. |
||
| </> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,82 +1,58 @@ | ||
| import { useState } from "react"; | ||
|
|
||
| type FaucetStatus = "idle" | "verifying-captcha" | "verifying-balance" | "claiming" | "success" | "error"; | ||
| const API_URL = import.meta.env.VITE_HYPERMILES_API_URL; | ||
|
|
||
| type FaucetStatus = "idle" | "claiming" | "success" | "error"; | ||
|
|
||
| interface FaucetResult { | ||
| amount: string; | ||
| txHash?: string; | ||
| amount: number; | ||
| walletAddress: string; | ||
| } | ||
|
|
||
| interface UseFaucetClaimReturn { | ||
| status: FaucetStatus; | ||
| error: string | null; | ||
| result: FaucetResult | null; | ||
| claim: (turnstileToken: string, address: string) => Promise<void>; | ||
| claim: (address: string, getAccessToken: () => Promise<string | null>) => Promise<void>; | ||
| reset: () => void; | ||
| } | ||
|
|
||
| async function postFaucet<T>(path: string, body: Record<string, string>): Promise<T> { | ||
| const res = await fetch(`/api/faucet/${path}`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| return res.json(); | ||
| } | ||
|
|
||
| export function useFaucetClaim(): UseFaucetClaimReturn { | ||
| const [status, setStatus] = useState<FaucetStatus>("idle"); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [result, setResult] = useState<FaucetResult | null>(null); | ||
|
|
||
| async function claim(turnstileToken: string, address: string) { | ||
| setStatus("verifying-captcha"); | ||
| async function claim(address: string, getAccessToken: () => Promise<string | null>) { | ||
| setStatus("claiming"); | ||
| setError(null); | ||
| setResult(null); | ||
|
|
||
| try { | ||
| const turnstileData = await postFaucet<{ success: boolean; sessionToken?: string; error?: string }>( | ||
| "verify-turnstile", | ||
| { token: turnstileToken }, | ||
| ); | ||
| if (!turnstileData.success || !turnstileData.sessionToken) | ||
| throw new Error(turnstileData.error || "Captcha verification failed"); | ||
| const sessionToken = turnstileData.sessionToken; | ||
| const token = await getAccessToken(); | ||
| if (!token) throw new Error("Not authenticated"); | ||
|
|
||
| setStatus("verifying-balance"); | ||
| const balanceData = await postFaucet<{ | ||
| success: boolean; | ||
| hasMinimumBalance?: boolean; | ||
| totalBalance?: string; | ||
| required?: string; | ||
| error?: string; | ||
| }>("verify-balance", { address, sessionToken }); | ||
| if (!balanceData.success) throw new Error(balanceData.error || "Balance check failed"); | ||
| if (!balanceData.hasMinimumBalance) | ||
| throw new Error(`Insufficient balance: $${balanceData.totalBalance} (need $${balanceData.required})`); | ||
|
|
||
| setStatus("claiming"); | ||
| const claimData = await postFaucet<{ | ||
| success: boolean; | ||
| amount?: string; | ||
| txHash?: string; | ||
| error?: string; | ||
| nextClaimTime?: number; | ||
| }>("claim", { | ||
| recipientAddress: address, | ||
| sessionToken, | ||
| authMethod: "wallet", | ||
| walletAddress: address, | ||
| const res = await fetch(`${API_URL}/faucet`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| body: JSON.stringify({ walletAddress: address }), | ||
| }); | ||
| if (!claimData.success) { | ||
| if (claimData.nextClaimTime) { | ||
| const hours = Math.max(1, Math.ceil((claimData.nextClaimTime * 1000 - Date.now()) / (1000 * 60 * 60))); | ||
| throw new Error(`Cooldown active. Try again in ~${hours}h`); | ||
| } | ||
| throw new Error(claimData.error || "Claim failed"); | ||
|
|
||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| let message = `Claim failed (${res.status})`; | ||
| try { | ||
| const data = JSON.parse(text); | ||
| if (data.error) message = data.error; | ||
| } catch {} | ||
| throw new Error(message); | ||
| } | ||
|
|
||
| setResult({ amount: claimData.amount || "1,000", txHash: claimData.txHash }); | ||
| const data = await res.json(); | ||
|
|
||
| setResult({ amount: data.amount, walletAddress: data.walletAddress }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's safer to validate the API response before updating the state. This ensures that the application doesn't end up with an invalid or incomplete if (!data || typeof data.amount !== "number" || !data.walletAddress) {
throw new Error("Invalid response from faucet API");
}
setResult({ amount: data.amount, walletAddress: data.walletAddress }); |
||
| setStatus("success"); | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : "Something went wrong"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Destructure the
loginfunction fromusePrivyto allow users to authenticate directly from the faucet modal if they are not already logged in.