Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Binary file added public/Icon_only_cyan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 0 additions & 16 deletions server/routes/api/faucet/[...path].ts

This file was deleted.

11 changes: 5 additions & 6 deletions src/components/trade/header/top-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, DropIcon, GearIcon, TerminalIcon, TrophyIcon } from "@phosphor-icons/react";
import { DownloadSimpleIcon, DropIcon, GearIcon, TrophyIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { useConnection } from "wagmi";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -55,12 +55,11 @@ export function TopNav() {
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-1.5">
<div className="size-5 rounded bg-primary-default/10 border border-primary-default/30 flex items-center justify-center">
<TerminalIcon className="size-3 text-primary-default" />
</div>
<img src="/hyperodd_icon.png" alt="Hyperodd" className="size-5 dark:hidden" />
<img src="/Icon_only_cyan.png" alt="Hyperodd" className="size-5 hidden dark:block" />
<span className="text-xs font-bold tracking-tight">
<span className="text-primary-default">HyperOdd</span>
<span className="text-text-950">Terminal</span>
<span className="text-primary-default">Hyperodd</span>
<span className="text-text-950"> Terminal</span>
</span>
</div>
<div className="h-4 w-px bg-border-200 hidden md:block" />
Expand Down
102 changes: 12 additions & 90 deletions src/components/trade/tradebox/faucet-modal.tsx
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Destructure the login function from usePrivy to allow users to authenticate directly from the faucet modal if they are not already logged in.

Suggested change
const { authenticated, getAccessToken } = usePrivy();
const { authenticated, getAccessToken, login } = 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") {
Expand Down Expand Up @@ -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>
Expand All @@ -155,7 +97,7 @@ export function FaucetModal() {
);
}

if (isProcessing) {
if (status === "claiming") {
return (
<Dialog open onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
Expand All @@ -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>
Expand Down Expand Up @@ -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"
Expand All @@ -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">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'Claim' button is disabled when the user is not authenticated via Privy (!authenticated), but there is no visual feedback or call-to-action explaining why. A user who has connected their wallet via wagmi but hasn't logged into Privy might find this confusing. Consider updating the button text to 'Login to Claim' or providing a brief message to guide the user when they are not authenticated.

<DropIcon className="size-4" />
<Trans>Claim 1,000 USDH</Trans>
<Trans>Claim 50 USDH</Trans>
</Button>
Comment on lines +177 to 180
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

							<Button
								variant="contained"
								onClick={authenticated ? handleClaim : login}
								className="w-full"
							>
								<DropIcon className="size-4" />
								{authenticated ? <Trans>Claim 50 USDH</Trans> : <Trans>Login to Claim</Trans>}
							</Button>

</>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/market/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type ExchangeScope = "all" | "perp" | "spot" | "builders-perp";
export const EXCHANGE_SCOPES: ExchangeScope[] = ["all", "perp", "spot", "builders-perp"];

export const DEFAULT_SELECTED_MARKETS: Record<ExchangeScope, string> = {
all: "BTC",
all: "VOLX-USDH",
perp: "BTC",
spot: "@107", // ETH/USDC
"builders-perp": "xyz:SILVER",
Expand Down
76 changes: 23 additions & 53 deletions src/lib/faucet/use-faucet-claim.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,52 @@
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");

const data = await res.json();

if (!res.ok) {
throw new Error(data.error || `Claim failed (${res.status})`);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The response is parsed as JSON before checking res.ok. If the API returns a non-JSON error (e.g., an HTML error page from a load balancer or proxy), res.json() will throw a syntax error, masking the actual HTTP error with an unhelpful message like "Unexpected token < in JSON...". It is safer to handle potential parsing failures or check the status code first to provide a more meaningful error message.

Suggested change
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || `Claim failed (${res.status})`);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || `Claim failed (${res.status})`);
}


setResult({ amount: claimData.amount || "1,000", txHash: claimData.txHash });
setResult({ amount: data.amount, walletAddress: data.walletAddress });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 result object if the backend returns unexpected data.

			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");
Expand Down
28 changes: 18 additions & 10 deletions src/locales/ar/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ msgid "% filled"
msgstr "% مكتمل"

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "$5+ USDC balance"
msgstr ""
#~ msgid "$5+ USDC balance"
#~ msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "24 hours"
Expand Down Expand Up @@ -288,19 +288,27 @@ msgid "Chart"
msgstr "الرسم البياني"

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Checking balance"
msgstr ""
#~ msgid "Checking balance"
#~ msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
#~ msgid "Claim 1,000 USDH"
#~ msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Claim 1,000 USDH"
msgid "Claim 50 USDH"
msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Claim failed"
msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Claiming USDH"
#~ msgid "Claiming USDH"
#~ msgstr ""

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Claiming USDH..."
msgstr ""

#: src/components/trade/positions/position-actions-dropdown.tsx
Expand Down Expand Up @@ -1478,8 +1486,8 @@ msgid "Remove from favorites"
msgstr "إزالة من المفضلة"

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Requirement"
msgstr ""
#~ msgid "Requirement"
#~ msgstr ""

#: src/components/pages/builder-page.tsx
#~ msgid "Requirements:"
Expand Down Expand Up @@ -2169,8 +2177,8 @@ msgstr ""
#~ msgstr "الخزائن"

#: src/components/trade/tradebox/faucet-modal.tsx
msgid "Verifying captcha"
msgstr ""
#~ msgid "Verifying captcha"
#~ msgstr ""

#: src/lib/trade/use-button-content.ts
msgid "Verifying..."
Expand Down
Loading
Loading