fix: simplify wallet choices and use bnb chain

This commit is contained in:
TerryM
2026-06-02 02:58:01 +08:00
parent b9fe7ff168
commit 0edcc80513
7 changed files with 197 additions and 248 deletions

View File

@@ -1,159 +1,84 @@
import { useConnectModal } from "@rainbow-me/rainbowkit";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useRef, useState } from "react";
import { useAccount, useSignMessage } from "wagmi";
import { useToast } from "../components/Toast";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import {
createTokenPocketLoginRequest,
fetchTokenPocketLoginResult,
requestWalletNonce,
verifyWalletSignature,
type TokenPocketLoginRequest,
} from "./api";
import { openWalletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider";
const pollIntervalMs = 1800;
type ModalState = "idle" | "signing";
type ModalState = "idle" | "tpLoading" | "tpPolling" | "rainbowSigning";
type WalletOption = {
kind: WalletKind;
labelKey: string;
};
const walletOptions: WalletOption[] = [
{ kind: "tokenPocket", labelKey: "walletTokenPocket" },
{ kind: "metaMask", labelKey: "walletMetaMask" },
{ kind: "imToken", labelKey: "walletImToken" },
];
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
return (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
ua,
) ||
(/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1)
);
}
export function WalletLoginModal() {
const { t } = useI18n();
const { showToast } = useToast();
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
useWallet();
const { openConnectModal } = useConnectModal();
const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage();
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState("");
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
null,
);
const [rainbowPending, setRainbowPending] = useState(false);
const rainbowSigningRef = useRef(false);
const [mobileDevice, setMobileDevice] = useState(false);
const [selectedWallet, setSelectedWallet] = useState<WalletKind | null>(null);
useEffect(() => {
if (!loginModalOpen) return;
setMobileDevice(isMobileDevice());
setSelectedWallet(null);
setError("");
}, [loginModalOpen]);
const close = () => {
if (state === "tpLoading" || state === "rainbowSigning") return;
if (state === "signing") return;
closeLoginModal();
setError("");
};
useEffect(() => {
if (!loginModalOpen || !tpRequest) return;
if (state !== "tpPolling") return;
let cancelled = false;
const abortController = new AbortController();
const poll = async () => {
try {
const result = await fetchTokenPocketLoginResult(
tpRequest.actionId,
abortController.signal,
);
if (cancelled) return;
if (result.status === "completed") {
const verified = await verifyWalletSignature({
address: result.address,
message: result.message,
signature: result.signature,
});
if (cancelled) return;
completeLogin(verified.token, verified.wallet);
showToast(t("walletLoginSuccess"));
setState("idle");
setTpRequest(null);
return;
}
if (result.status === "expired" || result.status === "failed") {
setState("idle");
setError(result.error || t("walletTpExpired"));
}
} catch (err) {
if (
!cancelled &&
!(err instanceof DOMException && err.name === "AbortError")
) {
setError(t("walletLoginFailed"));
}
}
};
void poll();
const timer = window.setInterval(() => void poll(), pollIntervalMs);
return () => {
cancelled = true;
abortController.abort();
window.clearInterval(timer);
};
}, [completeLogin, loginModalOpen, state, tpRequest, t, showToast]);
useEffect(() => {
if (!rainbowPending || !isConnected || !address) return;
if (rainbowSigningRef.current) return;
rainbowSigningRef.current = true;
setState("rainbowSigning");
setError("");
void (async () => {
try {
const nonce = await requestWalletNonce(address);
const signature = await signMessageAsync({ message: nonce.message });
const verified = await verifyWalletSignature({
address,
message: nonce.message,
signature,
});
completeLogin(verified.token, verified.wallet);
showToast(t("walletLoginSuccess"));
} catch (err) {
const message =
err instanceof Error ? err.message : t("walletLoginFailed");
setError(message || t("walletLoginFailed"));
showToast(t("walletLoginFailed"), "error");
} finally {
setState("idle");
setRainbowPending(false);
rainbowSigningRef.current = false;
}
})();
}, [
address,
isConnected,
rainbowPending,
showToast,
signMessageAsync,
t,
completeLogin,
]);
if (!loginModalOpen) return null;
const startInjected = async () => {
setError("");
setState("idle");
await signInInjected().catch(() => undefined);
const walletName = (kind: WalletKind) => {
if (kind === "tokenPocket") return t("walletTokenPocket");
if (kind === "metaMask") return t("walletMetaMask");
return t("walletImToken");
};
const startTokenPocketQr = async () => {
const chooseWallet = async (kind: WalletKind) => {
setError("");
setState("tpLoading");
try {
const req = await createTokenPocketLoginRequest();
setTpRequest(req);
setState("tpPolling");
} catch {
setState("idle");
setError(t("walletTpQrFailed"));
setSelectedWallet(kind);
const injectedWallet = getInjectedWallet(kind);
if (!injectedWallet) {
if (mobileDevice) {
openWalletDeepLink(kind);
return;
}
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return;
}
};
const startRainbowFallback = () => {
setError("");
setRainbowPending(true);
openConnectModal?.();
if (!openConnectModal) setError(t("walletRainbowUnavailable"));
setState("signing");
await signInInjected(kind)
.catch((err) => {
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
})
.finally(() => setState("idle"));
};
return (
@@ -186,97 +111,38 @@ export function WalletLoginModal() {
</div>
<div className="mt-5 grid gap-3">
<button
type="button"
onClick={() => void startInjected()}
className="rounded-2xl border border-ark-gold/40 bg-ark-gold/10 p-4 text-left transition hover:bg-ark-gold/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
>
<span className="block font-semibold text-ark-gold2">
{t("walletInjected")}
</span>
<span className="mt-1 block text-sm text-neutral-400">
{t("walletInjectedDesc")}
</span>
</button>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="font-semibold text-white">
{t("walletTokenPocketQr")}
</h3>
<p className="mt-1 text-sm leading-6 text-neutral-400">
{t("walletTokenPocketQrDesc")}
</p>
</div>
<button
type="button"
onClick={() => void startTokenPocketQr()}
disabled={state === "tpLoading"}
className="shrink-0 rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{state === "tpLoading" ? t("loading") : t("walletGenerateQr")}
</button>
</div>
{tpRequest ? (
<div className="mt-4 grid place-items-center gap-3 rounded-2xl bg-white p-4 text-center">
<QRCodeSVG value={tpRequest.qrUrl} size={196} level="M" />
<p className="text-xs font-medium text-neutral-700">
{t("walletQrUseAnotherDevice")}
</p>
</div>
) : null}
<p className="text-sm font-medium text-neutral-300">
{mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")}
</p>
<div className="grid gap-2">
{walletOptions.map((option) => {
const signingThis =
state === "signing" && selectedWallet === option.kind;
return (
<button
key={option.kind}
type="button"
onClick={() => void chooseWallet(option.kind)}
disabled={state === "signing"}
className="flex items-center justify-between rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left text-base font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
>
<span>{t(option.labelKey)}</span>
<span className="text-sm text-ark-gold">
{signingThis
? t("walletSigning")
: mobileDevice
? t("walletOpen")
: t("walletConnect")}
</span>
</button>
);
})}
</div>
<div className="grid gap-2 sm:grid-cols-3">
<button
type="button"
onClick={() => openWalletDeepLink("tokenPocket")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenTokenPocket")}
</button>
<button
type="button"
onClick={() => openWalletDeepLink("metaMask")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenMetaMask")}
</button>
<button
type="button"
onClick={() => openWalletDeepLink("imToken")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenImToken")}
</button>
</div>
<div className="rounded-2xl border border-dashed border-white/15 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="font-semibold text-white">
{t("walletRainbowFallback")}
</h3>
<p className="mt-1 text-sm leading-6 text-neutral-400">
{t("walletRainbowFallbackDesc")}
</p>
</div>
<button
type="button"
onClick={startRainbowFallback}
disabled={state === "rainbowSigning"}
className="shrink-0 rounded-full border border-ark-gold/50 px-4 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
>
{state === "rainbowSigning"
? t("walletSigning")
: t("walletOpenRainbow")}
</button>
</div>
<p className="mt-3 text-xs leading-5 text-yellow-200/80">
{t("walletNetworkWarning")}
{!mobileDevice ? (
<p className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
{t("walletDesktopHint")}
</p>
</div>
) : null}
</div>
{error ? (