feat: redesign wallet login and favorites, fix desktop/mobile bugs
- Remove forced BNB chain switch on injected login (signature is chain-agnostic) - Refine isMobileDevice so touch Macs stay on desktop flow - Wire RainbowKit/WalletConnect as a real MetaMask/imToken QR fallback, gated on a valid VITE_WALLETCONNECT_PROJECT_ID - Rebuild login modal: single desktop primary action, collapsible other methods, mobile open-app fallback feedback, brand icons - Add My Favorites entry points (header, mobile menu, wallet dropdown) - Favorites page: error retry, mobile filter drawer - Auto sign-out and re-login prompt on favorites 401 - Full native translations for all wallet strings across 7 locales Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
src/wallet/WalletBrandIcon.tsx
Normal file
33
src/wallet/WalletBrandIcon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { WalletKind } from "./injected";
|
||||
|
||||
type Brand = { bg: string; label: string };
|
||||
|
||||
const brands: Record<WalletKind, Brand> = {
|
||||
tokenPocket: { bg: "#2980FE", label: "TP" },
|
||||
metaMask: { bg: "#F6851B", label: "M" },
|
||||
imToken: { bg: "#11C4D1", label: "im" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight brand badge for wallet buttons — a rounded square tinted with the
|
||||
* wallet's brand color and its monogram. Keeps bundle small while making each
|
||||
* wallet visually distinguishable.
|
||||
*/
|
||||
export function WalletBrandIcon({
|
||||
kind,
|
||||
size = 28,
|
||||
}: {
|
||||
kind: WalletKind;
|
||||
size?: number;
|
||||
}) {
|
||||
const brand = brands[kind];
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ width: size, height: size, backgroundColor: brand.bg }}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg text-[11px] font-bold leading-none text-white"
|
||||
>
|
||||
{brand.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Heart } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useLocalizedPath } from "../useLocalizedPath";
|
||||
import { shortenAddress, useWallet } from "./WalletProvider";
|
||||
|
||||
export function WalletButton({
|
||||
@@ -10,6 +13,7 @@ export function WalletButton({
|
||||
onOpenLogin?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const lp = useLocalizedPath();
|
||||
const wallet = useWallet();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -53,6 +57,14 @@ export function WalletButton({
|
||||
<div className="truncate px-3 py-2 text-xs text-neutral-400">
|
||||
{wallet.address}
|
||||
</div>
|
||||
<Link
|
||||
to={lp("/favorites")}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-neutral-100 transition hover:bg-ark-gold/10 hover:text-ark-gold"
|
||||
>
|
||||
<Heart size={16} strokeWidth={2} />
|
||||
{t("favorites")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,86 +1,164 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { openWalletDeepLink } from "./deepLinks";
|
||||
import { getInjectedWallet, type WalletKind } from "./injected";
|
||||
import {
|
||||
createTokenPocketLoginRequest,
|
||||
fetchTokenPocketLoginResult,
|
||||
verifyWalletSignature,
|
||||
type TokenPocketLoginRequest,
|
||||
} from "./api";
|
||||
import { openWalletDeepLink, walletDownloadUrl } from "./deepLinks";
|
||||
import { getInjectedEthereum, type WalletKind } from "./injected";
|
||||
import { useWallet } from "./WalletProvider";
|
||||
import { useWalletConnectLogin } from "./useWalletConnectLogin";
|
||||
import { WalletBrandIcon } from "./WalletBrandIcon";
|
||||
|
||||
type ModalState = "idle" | "signing";
|
||||
const pollIntervalMs = 1800;
|
||||
|
||||
type WalletOption = {
|
||||
kind: WalletKind;
|
||||
labelKey: string;
|
||||
};
|
||||
type ModalState = "idle" | "signing" | "tpLoading" | "tpPolling";
|
||||
|
||||
const walletOptions: WalletOption[] = [
|
||||
{ kind: "tokenPocket", labelKey: "walletTokenPocket" },
|
||||
{ kind: "metaMask", labelKey: "walletMetaMask" },
|
||||
{ kind: "imToken", labelKey: "walletImToken" },
|
||||
const appWallets: { kind: WalletKind; labelKey: string }[] = [
|
||||
{ kind: "tokenPocket", labelKey: "walletOpenTokenPocket" },
|
||||
{ kind: "metaMask", labelKey: "walletOpenMetaMask" },
|
||||
{ kind: "imToken", labelKey: "walletOpenImToken" },
|
||||
];
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
return (
|
||||
if (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||
ua,
|
||||
) ||
|
||||
(/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// iPadOS 13+ reports a desktop "Macintosh" UA. A genuine touch-primary iPad
|
||||
// exposes a coarse pointer; a Mac (even with a touch peripheral) keeps a fine
|
||||
// pointer, so it stays on the desktop flow instead of the wallet-app jump.
|
||||
const coarsePointer =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(pointer: coarse)").matches;
|
||||
return /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1 && coarsePointer;
|
||||
}
|
||||
|
||||
export function WalletLoginModal() {
|
||||
const { t } = useI18n();
|
||||
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
|
||||
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
|
||||
useWallet();
|
||||
const wc = useWalletConnectLogin();
|
||||
const [state, setState] = useState<ModalState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
const [mobileDevice, setMobileDevice] = useState(false);
|
||||
const [selectedWallet, setSelectedWallet] = useState<WalletKind | null>(null);
|
||||
const [hasInjected, setHasInjected] = useState(false);
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [openingWallet, setOpeningWallet] = useState<WalletKind | null>(null);
|
||||
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginModalOpen) return;
|
||||
setMobileDevice(isMobileDevice());
|
||||
setSelectedWallet(null);
|
||||
setHasInjected(Boolean(getInjectedEthereum()));
|
||||
setShowOther(false);
|
||||
setOpeningWallet(null);
|
||||
setTpRequest(null);
|
||||
setState("idle");
|
||||
setError("");
|
||||
}, [loginModalOpen]);
|
||||
wc.reset();
|
||||
}, [loginModalOpen, wc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginModalOpen || !tpRequest || 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);
|
||||
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, t, tpRequest]);
|
||||
|
||||
const busy = state === "signing" || state === "tpLoading";
|
||||
|
||||
const close = () => {
|
||||
if (state === "signing") return;
|
||||
if (busy) return;
|
||||
closeLoginModal();
|
||||
setError("");
|
||||
};
|
||||
|
||||
if (!loginModalOpen) return null;
|
||||
|
||||
const walletName = (kind: WalletKind) => {
|
||||
if (kind === "tokenPocket") return t("walletTokenPocket");
|
||||
if (kind === "metaMask") return t("walletMetaMask");
|
||||
return t("walletImToken");
|
||||
};
|
||||
const withWallet = (key: string, kind: WalletKind) =>
|
||||
t(key).replace("{wallet}", t(walletNameKey(kind)));
|
||||
|
||||
const chooseWallet = async (kind: WalletKind) => {
|
||||
const signInjected = async () => {
|
||||
setError("");
|
||||
setSelectedWallet(kind);
|
||||
|
||||
const injectedWallet = getInjectedWallet(kind);
|
||||
if (!injectedWallet) {
|
||||
if (mobileDevice) {
|
||||
openWalletDeepLink(kind);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState("signing");
|
||||
await signInInjected(kind)
|
||||
await signInInjected()
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
|
||||
})
|
||||
.finally(() => setState("idle"));
|
||||
};
|
||||
|
||||
const openApp = (kind: WalletKind) => {
|
||||
setError("");
|
||||
setOpeningWallet(kind);
|
||||
openWalletDeepLink(kind);
|
||||
};
|
||||
|
||||
const startTokenPocketQr = async () => {
|
||||
setError("");
|
||||
setState("tpLoading");
|
||||
try {
|
||||
const req = await createTokenPocketLoginRequest();
|
||||
setTpRequest(req);
|
||||
setState("tpPolling");
|
||||
} catch {
|
||||
setState("idle");
|
||||
setError(t("walletTpQrFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-end justify-center bg-black/70 px-3 pb-3 pt-10 backdrop-blur-sm md:items-center md:p-6"
|
||||
@@ -111,46 +189,157 @@ export function WalletLoginModal() {
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<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 (
|
||||
{!mobileDevice || hasInjected ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInjected()}
|
||||
disabled={busy}
|
||||
className="flex items-center justify-center gap-2 rounded-2xl bg-ark-gold px-4 py-4 text-base font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{state === "signing"
|
||||
? t("walletSigning")
|
||||
: mobileDevice
|
||||
? t("walletUseCurrent")
|
||||
: t("walletInjected")}
|
||||
</button>
|
||||
{!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>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-medium text-neutral-300">
|
||||
{t("walletOpenWalletApp")}
|
||||
</p>
|
||||
{appWallets.map((option) => (
|
||||
<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"
|
||||
onClick={() => openApp(option.kind)}
|
||||
className="flex items-center gap-3 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"
|
||||
>
|
||||
<WalletBrandIcon kind={option.kind} />
|
||||
<span>{t(option.labelKey)}</span>
|
||||
<span className="text-sm text-ark-gold">
|
||||
{signingThis
|
||||
? t("walletSigning")
|
||||
: mobileDevice
|
||||
? t("walletOpen")
|
||||
: t("walletConnect")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{openingWallet ? (
|
||||
<div className="grid gap-2 rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
|
||||
<p>{withWallet("walletOpening", openingWallet)}</p>
|
||||
<p>{t("walletAppNotInstalled")}</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<a
|
||||
href={walletDownloadUrl(openingWallet)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-ark-gold/40 px-3 py-1.5 font-semibold text-ark-gold transition hover:bg-ark-gold/10"
|
||||
>
|
||||
{withWallet("walletDownloadApp", openingWallet)}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openApp(openingWallet)}
|
||||
className="rounded-full border border-white/15 px-3 py-1.5 font-semibold text-neutral-200 transition hover:border-ark-gold/40 hover:text-ark-gold"
|
||||
>
|
||||
{t("walletRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-white/15">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOther((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold text-ark-gold transition hover:text-ark-gold2"
|
||||
>
|
||||
<span>{t("walletOtherMethods")}</span>
|
||||
<span>{showOther ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
{showOther ? (
|
||||
<div className="grid gap-4 px-4 pb-4">
|
||||
{/* TokenPocket QR — stable path for China users (works on desktop too). */}
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-semibold text-neutral-200">
|
||||
{t("walletTokenPocketQr")}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-neutral-400">
|
||||
{t("walletTokenPocketQrDesc")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startTokenPocketQr()}
|
||||
disabled={state === "tpLoading"}
|
||||
className="justify-self-start 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>
|
||||
{tpRequest ? (
|
||||
<div className="mt-1 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
||||
<QRCodeSVG value={tpRequest.qrUrl} size={180} level="M" />
|
||||
<p className="text-xs font-medium text-neutral-700">
|
||||
{t("walletQrUseAnotherDevice")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */}
|
||||
<div className="grid gap-2 border-t border-white/10 pt-4">
|
||||
<p className="text-sm font-semibold text-neutral-200">
|
||||
{t("walletRainbowFallback")}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-neutral-400">
|
||||
{t("walletRainbowFallbackDesc")}
|
||||
</p>
|
||||
{wc.available ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => wc.start()}
|
||||
disabled={wc.state !== "idle"}
|
||||
className="justify-self-start 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"
|
||||
>
|
||||
{wc.state === "connecting"
|
||||
? t("walletConnecting")
|
||||
: wc.state === "signing"
|
||||
? t("walletSigning")
|
||||
: t("walletOpenRainbow")}
|
||||
</button>
|
||||
<p className="text-xs leading-5 text-amber-300/80">
|
||||
{t("walletNetworkWarning")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs leading-5 text-neutral-500">
|
||||
{t("walletRainbowUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{error || wc.error ? (
|
||||
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
{error || wc.error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function walletNameKey(kind: WalletKind): string {
|
||||
if (kind === "tokenPocket") return "walletTokenPocket";
|
||||
if (kind === "metaMask") return "walletMetaMask";
|
||||
return "walletImToken";
|
||||
}
|
||||
|
||||
@@ -29,3 +29,13 @@ export function openWalletDeepLink(kind: WalletKind): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.location.href = walletDeepLink(kind);
|
||||
}
|
||||
|
||||
const downloadUrls: Record<WalletKind, string> = {
|
||||
tokenPocket: "https://www.tokenpocket.pro/en/download/app",
|
||||
metaMask: "https://metamask.io/download/",
|
||||
imToken: "https://token.im/download",
|
||||
};
|
||||
|
||||
export function walletDownloadUrl(kind: WalletKind): string {
|
||||
return downloadUrls[kind];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { requestWalletNonce, verifyWalletSignature } from "./api";
|
||||
|
||||
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
||||
|
||||
const bnbChainIdHex = "0x38";
|
||||
|
||||
export type EthereumProvider = {
|
||||
isMetaMask?: boolean;
|
||||
isTokenPocket?: boolean;
|
||||
@@ -36,30 +34,6 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null {
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: "wallet_switchEthereumChain",
|
||||
params: [{ chainId: bnbChainIdHex }],
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as { code?: number | string }).code;
|
||||
if (code !== 4902 && code !== "4902") throw error;
|
||||
await ethereum.request({
|
||||
method: "wallet_addEthereumChain",
|
||||
params: [
|
||||
{
|
||||
blockExplorerUrls: ["https://bscscan.com"],
|
||||
chainId: bnbChainIdHex,
|
||||
chainName: "BNB Smart Chain",
|
||||
nativeCurrency: { decimals: 18, name: "BNB", symbol: "BNB" },
|
||||
rpcUrls: ["https://bsc-dataseed.binance.org"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
||||
token: string;
|
||||
wallet: string;
|
||||
@@ -67,8 +41,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
||||
const ethereum = getInjectedWallet(kind);
|
||||
if (!ethereum) throw new Error("No injected wallet found");
|
||||
|
||||
await ensureBnbChain(ethereum);
|
||||
|
||||
// Login is signature-only (EIP-191 personal_sign). The backend verifies the
|
||||
// recovered address and never inspects chainId, so we deliberately do NOT
|
||||
// switch or add any chain — that only adds a failure-prone wallet popup.
|
||||
const accounts = await ethereum.request<string[]>({
|
||||
method: "eth_requestAccounts",
|
||||
});
|
||||
|
||||
84
src/wallet/useWalletConnectLogin.ts
Normal file
84
src/wallet/useWalletConnectLogin.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useConnectModal } from "@rainbow-me/rainbowkit";
|
||||
import { useAccount, useDisconnect, useSignMessage } from "wagmi";
|
||||
import { requestWalletNonce, verifyWalletSignature } from "./api";
|
||||
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
|
||||
import { useWallet } from "./WalletProvider";
|
||||
|
||||
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
|
||||
|
||||
/**
|
||||
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
|
||||
*
|
||||
* Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account
|
||||
* is connected, request a nonce, sign it with `personal_sign` through wagmi,
|
||||
* verify against the backend and complete our own JWT login. The wagmi/WC
|
||||
* session is only needed for the signature, so we disconnect right after.
|
||||
*
|
||||
* Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is
|
||||
* missing `available` is false and `start` is a no-op, so callers can hide or
|
||||
* disable the entry instead of triggering a connect with a fake project id.
|
||||
*/
|
||||
export function useWalletConnectLogin() {
|
||||
const available = hasWalletConnectProjectId();
|
||||
const { completeLogin } = useWallet();
|
||||
const { address, isConnected } = useAccount();
|
||||
const { signMessageAsync } = useSignMessage();
|
||||
const { disconnect } = useDisconnect();
|
||||
const { openConnectModal } = useConnectModal();
|
||||
const [state, setState] = useState<WalletConnectLoginState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
pendingRef.current = false;
|
||||
setState("idle");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!available) return;
|
||||
setError("");
|
||||
pendingRef.current = true;
|
||||
setState("connecting");
|
||||
// When already connected, openConnectModal is undefined; the effect below
|
||||
// picks up the existing account and proceeds straight to signing.
|
||||
openConnectModal?.();
|
||||
}, [available, openConnectModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRef.current || !isConnected || !address) return;
|
||||
pendingRef.current = false;
|
||||
setState("signing");
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const nonce = await requestWalletNonce(address);
|
||||
const signature = await signMessageAsync({ message: nonce.message });
|
||||
const verified = await verifyWalletSignature({
|
||||
address,
|
||||
message: nonce.message,
|
||||
signature,
|
||||
});
|
||||
if (cancelled) return;
|
||||
completeLogin(verified.token, verified.wallet);
|
||||
setState("idle");
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "WalletConnect login failed",
|
||||
);
|
||||
setState("idle");
|
||||
}
|
||||
} finally {
|
||||
// We only needed a one-off signature, not a persistent wagmi session.
|
||||
disconnect();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [address, completeLogin, disconnect, isConnected, signMessageAsync]);
|
||||
|
||||
return { available, state, error, start, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user