terry-wallet-login #15
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。",
|
||||||
|
|||||||
@@ -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에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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í.",
|
||||||
|
|||||||
@@ -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 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,194 +123,174 @@ 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">
|
||||||
<h2
|
{step === "method" ? (
|
||||||
id="wallet-login-title"
|
<button
|
||||||
className="text-xl font-semibold text-white"
|
type="button"
|
||||||
>
|
onClick={back}
|
||||||
{t("walletLoginTitle")}
|
aria-label={t("walletBack")}
|
||||||
</h2>
|
className={`${iconButtonClass} mt-0.5`}
|
||||||
<p className="mt-2 text-sm leading-6 text-neutral-400">
|
>
|
||||||
{t("walletLoginDesc")}
|
<ChevronLeft size={18} />
|
||||||
</p>
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2
|
||||||
|
id="wallet-login-title"
|
||||||
|
className="text-xl font-semibold text-white"
|
||||||
|
>
|
||||||
|
{t("walletLoginTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-neutral-400">
|
||||||
|
{step === "wallet"
|
||||||
|
? t("walletLoginDesc")
|
||||||
|
: t("walletChooseMethod")}
|
||||||
|
</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
|
|
||||||
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>
|
|
||||||
) : 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
|
<button
|
||||||
|
key={kind}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void startTokenPocketLogin()}
|
onClick={() => pickWallet(kind)}
|
||||||
disabled={state === "tpLoading"}
|
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="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} />
|
<WalletBrandIcon kind={kind} size={32} />
|
||||||
{state === "tpLoading"
|
<span className="flex-1 text-base font-semibold text-neutral-100">
|
||||||
? t("loading")
|
{walletName(kind)}
|
||||||
: mobileDevice
|
</span>
|
||||||
? t("walletTpLoginBtn")
|
<ChevronRight size={18} className="text-neutral-500" />
|
||||||
: t("walletGenerateQr")}
|
|
||||||
</button>
|
</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">
|
</div>
|
||||||
<p className="flex items-center gap-2 text-xs leading-5 text-neutral-300">
|
) : selected ? (
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
<div className="mt-5 grid gap-3">
|
||||||
{t("walletTpWaiting")}
|
{/* Selected wallet header. */}
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
|
{!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}
|
||||||
|
|
||||||
|
{/* Method: browser wallet (injected). */}
|
||||||
|
{(() => {
|
||||||
|
const ok = injectedAvailable(selected);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => void signInjectedFor(selected)}
|
||||||
window.location.href = tpRequest.qrUrl;
|
disabled={!ok || busy}
|
||||||
}}
|
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
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"
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("walletTpReopen")}
|
<span className="flex items-center gap-2 text-base font-semibold text-neutral-100">
|
||||||
</button>
|
{state === "signing" ? (
|
||||||
</div>
|
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{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}
|
) : null}
|
||||||
</div>
|
{mobileDevice ? t("walletUseCurrent") : t("walletInjected")}
|
||||||
) : null}
|
</span>
|
||||||
|
<span className="text-xs leading-5 text-neutral-400">
|
||||||
|
{ok
|
||||||
|
? t("walletInjectedDesc")
|
||||||
|
: mobileDevice
|
||||||
|
? t("walletOpenWalletAppDesc")
|
||||||
|
: t("walletInstallSelected").replace(
|
||||||
|
"{wallet}",
|
||||||
|
walletName(selected),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* MetaMask / imToken QR via WalletConnect — needs a real id. */}
|
{/* Method: scan to log in. */}
|
||||||
<div
|
{(() => {
|
||||||
className={
|
const ok = qrAvailable();
|
||||||
mobileDevice && !hasInjected
|
const isTp = selected === "tokenPocket";
|
||||||
? "grid gap-2 border-t border-white/10 pt-4"
|
const qrBusy = wc.state !== "idle";
|
||||||
: "grid gap-2"
|
const qrLabel =
|
||||||
}
|
wc.state === "connecting"
|
||||||
>
|
? t("walletConnecting")
|
||||||
<p className="text-sm font-semibold text-neutral-200">
|
: wc.state === "signing"
|
||||||
{t("walletRainbowFallback")}
|
? t("walletSigning")
|
||||||
</p>
|
: isTp && mobileDevice
|
||||||
<p className="text-xs leading-5 text-neutral-400">
|
? t("walletTpLoginBtn")
|
||||||
{t("walletRainbowFallbackDesc")}
|
: t("walletQrLogin");
|
||||||
</p>
|
return (
|
||||||
{wc.available ? (
|
<div className="grid gap-2">
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => chooseQr(selected)}
|
||||||
onClick={() => wc.start()}
|
disabled={!ok || qrBusy || busy}
|
||||||
disabled={wc.state !== "idle"}
|
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
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"
|
ok
|
||||||
>
|
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
||||||
{wc.state === "connecting"
|
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
|
||||||
? t("walletConnecting")
|
}`}
|
||||||
: wc.state === "signing"
|
>
|
||||||
? t("walletSigning")
|
<span className="text-base font-semibold text-neutral-100">
|
||||||
: t("walletOpenRainbow")}
|
{qrLabel}
|
||||||
</button>
|
</span>
|
||||||
<p className="text-xs leading-5 text-amber-300/80">
|
<span className="text-xs leading-5 text-neutral-400">
|
||||||
{t("walletNetworkWarning")}
|
{ok
|
||||||
</p>
|
? isTp
|
||||||
</>
|
? mobileDevice
|
||||||
) : (
|
? t("walletTpMobileDesc")
|
||||||
<p className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs leading-5 text-neutral-500">
|
: t("walletTokenPocketQrDesc")
|
||||||
{t("walletRainbowUnavailable")}
|
: t("walletRainbowFallbackDesc")
|
||||||
|
: t("walletRainbowUnavailable")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{ok ? (
|
||||||
|
<p className="text-xs leading-5 text-amber-300/80">
|
||||||
|
{t("walletNetworkWarning")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
{wc.qrUri ? (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
) : null}
|
})()}
|
||||||
</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">
|
||||||
|
|||||||
@@ -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(
|
||||||
if (!available) return;
|
async (preferredWallet?: WalletKind) => {
|
||||||
setError("");
|
if (!available) return;
|
||||||
pendingRef.current = true;
|
setError("");
|
||||||
setState("connecting");
|
setQrUri("");
|
||||||
// When already connected, openConnectModal is undefined; the effect below
|
pendingRef.current = true;
|
||||||
// picks up the existing account and proceeds straight to signing.
|
setState("connecting");
|
||||||
openConnectModal?.();
|
|
||||||
}, [available, openConnectModal]);
|
const connector =
|
||||||
|
connectors.find((item) => item.id === preferredWallet) ??
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user