terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
10 changed files with 317 additions and 288 deletions
Showing only changes of commit f0209eb894 - Show all commits

View File

@@ -186,6 +186,8 @@ export const enDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "Back",
walletChooseMethod: "Choose how to log in",
walletTokenPocketLogin: "TokenPocket login", walletTokenPocketLogin: "TokenPocket login",
walletTpMobileDesc: walletTpMobileDesc:
"Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.", "Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.",

View File

@@ -186,6 +186,8 @@ export const idDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "Kembali",
walletChooseMethod: "Pilih cara masuk",
walletTokenPocketLogin: "Masuk TokenPocket", walletTokenPocketLogin: "Masuk TokenPocket",
walletTpMobileDesc: walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.", "Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.",

View File

@@ -206,6 +206,8 @@ export const jaDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "戻る",
walletChooseMethod: "ログイン方法を選択",
walletTokenPocketLogin: "TokenPocket ログイン", walletTokenPocketLogin: "TokenPocket ログイン",
walletTpMobileDesc: walletTpMobileDesc:
"TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。", "TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。",

View File

@@ -183,6 +183,8 @@ export const koDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "뒤로",
walletChooseMethod: "로그인 방법 선택",
walletTokenPocketLogin: "TokenPocket 로그인", walletTokenPocketLogin: "TokenPocket 로그인",
walletTpMobileDesc: walletTpMobileDesc:
"TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.", "TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.",

View File

@@ -185,6 +185,8 @@ export const msDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "Kembali",
walletChooseMethod: "Pilih cara log masuk",
walletTokenPocketLogin: "Log masuk TokenPocket", walletTokenPocketLogin: "Log masuk TokenPocket",
walletTpMobileDesc: walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.", "Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.",

View File

@@ -183,6 +183,8 @@ export const viDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "Quay lại",
walletChooseMethod: "Chọn cách đăng nhập",
walletTokenPocketLogin: "Đăng nhập TokenPocket", walletTokenPocketLogin: "Đăng nhập TokenPocket",
walletTpMobileDesc: walletTpMobileDesc:
"Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.", "Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.",

View File

@@ -176,6 +176,8 @@ export const zhDict: Dict = {
walletTokenPocket: "TokenPocket", walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask", walletMetaMask: "MetaMask",
walletImToken: "imToken", walletImToken: "imToken",
walletBack: "返回",
walletChooseMethod: "选择登录方式",
walletTokenPocketLogin: "TokenPocket 登录", walletTokenPocketLogin: "TokenPocket 登录",
walletTpMobileDesc: walletTpMobileDesc:
"在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。", "在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。",

View File

@@ -37,6 +37,24 @@ export function WalletButton({
}, [open]); }, [open]);
if (wallet.status === "loggedIn" && wallet.address) { if (wallet.status === "loggedIn" && wallet.address) {
if (compact) {
return (
<div className="grid w-full gap-2">
<div className="inline-flex h-10 w-full items-center justify-center rounded-full border border-ark-gold/45 bg-ark-gold/10 px-3 text-sm font-semibold text-ark-gold2">
<span className="mr-2 h-2 w-2 rounded-full bg-emerald-400" />
{shortenAddress(wallet.address)}
</div>
<button
type="button"
onClick={() => wallet.logout()}
className="h-10 w-full rounded-full border border-red-400/35 bg-red-500/10 px-4 text-sm font-semibold text-red-200 transition hover:bg-red-500/15"
>
{t("walletDisconnect")}
</button>
</div>
);
}
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <button

View File

@@ -1,28 +1,17 @@
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { LoaderCircle } from "lucide-react"; import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import {
createTokenPocketLoginRequest, import { getInjectedWallet, type WalletKind } from "./injected";
fetchTokenPocketLoginResult,
verifyWalletSignature,
type TokenPocketLoginRequest,
} from "./api";
import { openWalletDeepLink, walletDownloadUrl } from "./deepLinks";
import { getInjectedEthereum, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin"; import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon"; import { WalletBrandIcon } from "./WalletBrandIcon";
const pollIntervalMs = 1800; type ModalState = "idle" | "signing";
type Step = "wallet" | "method";
type ModalState = "idle" | "signing" | "tpLoading" | "tpPolling"; const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
const appWallets: { kind: WalletKind; labelKey: string }[] = [
{ kind: "tokenPocket", labelKey: "walletOpenTokenPocket" },
{ kind: "metaMask", labelKey: "walletOpenMetaMask" },
{ kind: "imToken", labelKey: "walletOpenImToken" },
];
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false; if (typeof navigator === "undefined") return false;
@@ -46,80 +35,27 @@ function isMobileDevice(): boolean {
export function WalletLoginModal() { export function WalletLoginModal() {
const { t } = useI18n(); const { t } = useI18n();
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } = const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
useWallet();
const wc = useWalletConnectLogin(); const wc = useWalletConnectLogin();
const [step, setStep] = useState<Step>("wallet");
const [selected, setSelected] = useState<WalletKind | null>(null);
const [state, setState] = useState<ModalState>("idle"); const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [mobileDevice, setMobileDevice] = useState(false); const [mobileDevice, setMobileDevice] = useState(false);
const [hasInjected, setHasInjected] = useState(false);
const [showOther, setShowOther] = useState(false); const resetWalletConnect = wc.reset;
const [openingWallet, setOpeningWallet] = useState<WalletKind | null>(null);
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
null,
);
useEffect(() => { useEffect(() => {
if (!loginModalOpen) return; if (!loginModalOpen) return;
setMobileDevice(isMobileDevice()); setMobileDevice(isMobileDevice());
setHasInjected(Boolean(getInjectedEthereum())); setStep("wallet");
setShowOther(false); setSelected(null);
setOpeningWallet(null);
setTpRequest(null);
setState("idle"); setState("idle");
setError(""); setError("");
wc.reset(); resetWalletConnect();
}, [loginModalOpen, wc]); }, [loginModalOpen, resetWalletConnect]);
useEffect(() => { const busy = state === "signing";
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");
setTpRequest(null);
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 = () => { const close = () => {
if (busy) return; if (busy) return;
@@ -129,47 +65,56 @@ export function WalletLoginModal() {
if (!loginModalOpen) return null; if (!loginModalOpen) return null;
const withWallet = (key: string, kind: WalletKind) => const walletName = (kind: WalletKind) => t(walletNameKey(kind));
t(key).replace("{wallet}", t(walletNameKey(kind))); // A wallet's browser flow only works if THAT wallet is injected right now
// (its desktop extension, or its in-app browser on mobile).
const injectedAvailable = (kind: WalletKind) =>
Boolean(getInjectedWallet(kind));
// All scan/app login paths use RainbowKit/WalletConnect on BNB Chain.
const qrAvailable = () => wc.available;
const signInjected = async () => { const resetFlow = () => {
setError(""); setError("");
if (!getInjectedEthereum()) { setState("idle");
setError(t("walletNoBrowserWalletDesc")); wc.reset();
};
const pickWallet = (kind: WalletKind) => {
resetFlow();
setSelected(kind);
setStep("method");
};
const back = () => {
if (busy) return;
resetFlow();
setSelected(null);
setStep("wallet");
};
const signInjectedFor = async (kind: WalletKind) => {
setError("");
if (!getInjectedWallet(kind)) {
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return; return;
} }
setState("signing"); setState("signing");
await signInInjected() await signInInjected(kind)
.catch((err) => { .catch((err) => {
setError(err instanceof Error ? err.message : t("walletLoginFailed")); setError(err instanceof Error ? err.message : t("walletLoginFailed"));
}) })
.finally(() => setState("idle")); .finally(() => setState("idle"));
}; };
const openApp = (kind: WalletKind) => { const chooseQr = (kind: WalletKind) => {
setError(""); setError("");
setOpeningWallet(kind); void wc.start(kind);
openWalletDeepLink(kind);
}; };
// TokenPocket login. The backend returns a `tpoutside://pull.activity` deep const iconButtonClass =
// link (a one-off SIGN request, not a dApp-browser link). On mobile we open "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold";
// it directly so TokenPocket only asks for a signature and then returns to
// THIS browser — the poll below finishes login here, no in-app browser. On
// desktop we render it as a QR to scan from a phone.
const startTokenPocketLogin = async () => {
setError("");
setState("tpLoading");
try {
const req = await createTokenPocketLoginRequest();
setTpRequest(req);
setState("tpPolling");
if (mobileDevice) window.location.href = req.qrUrl;
} catch {
setState("idle");
setError(t("walletTpQrFailed"));
}
};
return ( return (
<div <div
@@ -178,9 +123,20 @@ export function WalletLoginModal() {
aria-modal="true" aria-modal="true"
aria-labelledby="wallet-login-title" aria-labelledby="wallet-login-title"
> >
<div className="max-h-[92dvh] w-full max-w-[520px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6"> <div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-3">
<div> <div className="flex min-w-0 items-start gap-2">
{step === "method" ? (
<button
type="button"
onClick={back}
aria-label={t("walletBack")}
className={`${iconButtonClass} mt-0.5`}
>
<ChevronLeft size={18} />
</button>
) : null}
<div className="min-w-0">
<h2 <h2
id="wallet-login-title" id="wallet-login-title"
className="text-xl font-semibold text-white" className="text-xl font-semibold text-white"
@@ -188,184 +144,153 @@ export function WalletLoginModal() {
{t("walletLoginTitle")} {t("walletLoginTitle")}
</h2> </h2>
<p className="mt-2 text-sm leading-6 text-neutral-400"> <p className="mt-2 text-sm leading-6 text-neutral-400">
{t("walletLoginDesc")} {step === "wallet"
? t("walletLoginDesc")
: t("walletChooseMethod")}
</p> </p>
</div> </div>
</div>
<button <button
type="button" type="button"
onClick={close} onClick={close}
className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold" aria-label={t("close")}
className={iconButtonClass}
> >
{t("close")} <X size={18} />
</button> </button>
</div> </div>
<div className="mt-5 grid gap-3"> {/* Step 1 — choose a wallet. */}
{/* Browser wallet: sign directly with the injected provider — the {step === "wallet" ? (
reliable path for a BNB-chain extension (desktop) or a wallet's <div className="mt-5 grid gap-2">
in-app browser. No WalletConnect relay involved. */} {wallets.map((kind) => (
{!mobileDevice || hasInjected ? (
<button <button
key={kind}
type="button" type="button"
onClick={() => void signInjected()} onClick={() => pickWallet(kind)}
disabled={busy} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
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" <WalletBrandIcon kind={kind} size={32} />
? t("walletSigning") <span className="flex-1 text-base font-semibold text-neutral-100">
: mobileDevice {walletName(kind)}
? t("walletUseCurrent") </span>
: t("walletInjected")} <ChevronRight size={18} className="text-neutral-500" />
</button>
) : null}
{/* TokenPocket login — universal path that returns to this browser. */}
<div className="grid gap-2 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-neutral-100">
{t("walletTokenPocketLogin")}
</p>
<p className="text-xs leading-5 text-neutral-400">
{mobileDevice
? t("walletTpMobileDesc")
: t("walletTokenPocketQrDesc")}
</p>
{!tpRequest ? (
<button
type="button"
onClick={() => void startTokenPocketLogin()}
disabled={state === "tpLoading"}
className="mt-1 inline-flex items-center justify-center gap-2 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"
>
<WalletBrandIcon kind="tokenPocket" size={20} />
{state === "tpLoading"
? t("loading")
: mobileDevice
? t("walletTpLoginBtn")
: t("walletGenerateQr")}
</button>
) : mobileDevice ? (
<div className="mt-1 grid gap-2 rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-4 py-3">
<p className="flex items-center gap-2 text-xs leading-5 text-neutral-300">
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
{t("walletTpWaiting")}
</p>
<button
type="button"
onClick={() => {
window.location.href = tpRequest.qrUrl;
}}
className="justify-self-start rounded-full border border-ark-gold/40 px-3 py-1.5 text-xs font-semibold text-ark-gold transition hover:bg-ark-gold/10"
>
{t("walletTpReopen")}
</button>
</div>
) : (
<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>
)}
</div>
{/* Other methods: open a wallet app (mobile) and WalletConnect QR. */}
<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">
{mobileDevice && !hasInjected ? (
<div className="grid gap-2">
<p className="text-sm font-semibold text-neutral-200">
{t("walletOpenWalletApp")}
</p>
{appWallets.map((option) => (
<button
key={option.kind}
type="button"
onClick={() => openApp(option.kind)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left text-sm 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>
</button> </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>
) : selected ? (
<div className="mt-5 grid gap-3">
{/* Selected wallet header. */}
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<WalletBrandIcon kind={selected} size={28} />
<span className="text-base font-semibold text-white">
{walletName(selected)}
</span>
</div> </div>
) : null}
</div> {!mobileDevice && !injectedAvailable(selected) ? (
<p className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-100">
{t("walletInstallSelected").replace(
"{wallet}",
walletName(selected),
)}
</p>
) : null} ) : null}
{/* MetaMask / imToken QR via WalletConnect — needs a real id. */} {/* Method: browser wallet (injected). */}
<div {(() => {
className={ const ok = injectedAvailable(selected);
mobileDevice && !hasInjected return (
? "grid gap-2 border-t border-white/10 pt-4"
: "grid gap-2"
}
>
<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 <button
type="button" type="button"
onClick={() => wc.start()} onClick={() => void signInjectedFor(selected)}
disabled={wc.state !== "idle"} disabled={!ok || busy}
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" className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
> >
{wc.state === "connecting" <span className="flex items-center gap-2 text-base font-semibold text-neutral-100">
{state === "signing" ? (
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null}
{mobileDevice ? t("walletUseCurrent") : t("walletInjected")}
</span>
<span className="text-xs leading-5 text-neutral-400">
{ok
? t("walletInjectedDesc")
: mobileDevice
? t("walletOpenWalletAppDesc")
: t("walletInstallSelected").replace(
"{wallet}",
walletName(selected),
)}
</span>
</button>
);
})()}
{/* Method: scan to log in. */}
{(() => {
const ok = qrAvailable();
const isTp = selected === "tokenPocket";
const qrBusy = wc.state !== "idle";
const qrLabel =
wc.state === "connecting"
? t("walletConnecting") ? t("walletConnecting")
: wc.state === "signing" : wc.state === "signing"
? t("walletSigning") ? t("walletSigning")
: t("walletOpenRainbow")} : isTp && mobileDevice
? t("walletTpLoginBtn")
: t("walletQrLogin");
return (
<div className="grid gap-2">
<button
type="button"
onClick={() => chooseQr(selected)}
disabled={!ok || qrBusy || busy}
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
>
<span className="text-base font-semibold text-neutral-100">
{qrLabel}
</span>
<span className="text-xs leading-5 text-neutral-400">
{ok
? isTp
? mobileDevice
? t("walletTpMobileDesc")
: t("walletTokenPocketQrDesc")
: t("walletRainbowFallbackDesc")
: t("walletRainbowUnavailable")}
</span>
</button> </button>
{ok ? (
<p className="text-xs leading-5 text-amber-300/80"> <p className="text-xs leading-5 text-amber-300/80">
{t("walletNetworkWarning")} {t("walletNetworkWarning")}
</p> </p>
</> ) : null}
) : (
<p className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs leading-5 text-neutral-500"> {wc.qrUri ? (
{t("walletRainbowUnavailable")} <div className="grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
<QRCodeSVG value={wc.qrUri} size={180} level="M" />
<p className="text-xs font-medium text-neutral-700">
{mobileDevice
? t("walletTpWaiting")
: t("walletQrUseAnotherDevice")}
</p> </p>
)}
</div>
</div> </div>
) : null} ) : null}
</div> </div>
);
})()}
</div> </div>
) : null}
{error || wc.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"> <p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">

View File

@@ -1,12 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useConnectModal } from "@rainbow-me/rainbowkit"; import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
import { useAccount, useDisconnect, useSignMessage } from "wagmi"; import { bsc } from "wagmi/chains";
import { requestWalletNonce, verifyWalletSignature } from "./api"; import { requestWalletNonce, verifyWalletSignature } from "./api";
import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
import type { WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; import { useWallet } from "./WalletProvider";
export type WalletConnectLoginState = "idle" | "connecting" | "signing"; export type WalletConnectLoginState = "idle" | "connecting" | "signing";
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent || "",
);
}
/** /**
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
* *
@@ -24,27 +32,90 @@ export function useWalletConnectLogin() {
const { completeLogin } = useWallet(); const { completeLogin } = useWallet();
const { address, isConnected } = useAccount(); const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage(); const { signMessageAsync } = useSignMessage();
const { connectAsync, connectors } = useConnect();
const { disconnect } = useDisconnect(); const { disconnect } = useDisconnect();
const { openConnectModal } = useConnectModal();
const [state, setState] = useState<WalletConnectLoginState>("idle"); const [state, setState] = useState<WalletConnectLoginState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [qrUri, setQrUri] = useState("");
const pendingRef = useRef(false); const pendingRef = useRef(false);
const cleanupMessageRef = useRef<(() => void) | null>(null);
const reset = useCallback(() => { const reset = useCallback(() => {
pendingRef.current = false; pendingRef.current = false;
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
setState("idle"); setState("idle");
setError(""); setError("");
setQrUri("");
}, []); }, []);
const start = useCallback(() => { const start = useCallback(
async (preferredWallet?: WalletKind) => {
if (!available) return; if (!available) return;
setError(""); setError("");
setQrUri("");
pendingRef.current = true; pendingRef.current = true;
setState("connecting"); setState("connecting");
// When already connected, openConnectModal is undefined; the effect below
// picks up the existing account and proceeds straight to signing. const connector =
openConnectModal?.(); connectors.find((item) => item.id === preferredWallet) ??
}, [available, openConnectModal]); connectors.find((item) => item.id === "walletConnect") ??
connectors.find((item) => item.type === "walletConnect");
if (!connector) {
pendingRef.current = false;
setQrUri("");
setState("idle");
setError("WalletConnect is not available");
return;
}
console.info("[wallet-login] walletconnect connector", {
preferredWallet,
connectorId: connector.id,
connectorName: connector.name,
connectorType: connector.type,
});
const onMessage = (message: { type: string; data?: unknown }) => {
if (
message.type !== "display_uri" ||
typeof message.data !== "string"
) {
return;
}
console.info("[wallet-login] walletconnect display_uri", {
preferredWallet,
connectorId: connector.id,
});
setQrUri(message.data);
if (preferredWallet === "tokenPocket" && isMobileDevice()) {
window.location.href = `tpoutside://wc?uri=${encodeURIComponent(
message.data,
)}`;
}
};
cleanupMessageRef.current?.();
connector.emitter.on("message", onMessage);
cleanupMessageRef.current = () =>
connector.emitter.off("message", onMessage);
try {
await connector.disconnect().catch(() => undefined);
await connectAsync({ chainId: bsc.id, connector });
} catch (err) {
pendingRef.current = false;
setState("idle");
setError(
err instanceof Error ? err.message : "WalletConnect login failed",
);
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
}
},
[available, connectAsync, connectors],
);
useEffect(() => { useEffect(() => {
if (!pendingRef.current || !isConnected || !address) return; if (!pendingRef.current || !isConnected || !address) return;
@@ -68,6 +139,7 @@ export function useWalletConnectLogin() {
setError( setError(
err instanceof Error ? err.message : "WalletConnect login failed", err instanceof Error ? err.message : "WalletConnect login failed",
); );
setQrUri("");
setState("idle"); setState("idle");
} }
} finally { } finally {
@@ -80,5 +152,5 @@ export function useWalletConnectLogin() {
}; };
}, [address, completeLogin, disconnect, isConnected, signMessageAsync]); }, [address, completeLogin, disconnect, isConnected, signMessageAsync]);
return { available, state, error, start, reset }; return { available, state, error, qrUri, start, reset };
} }