2026-06-02 03:43:13 +08:00
|
|
|
import { QRCodeSVG } from "qrcode.react";
|
2026-06-02 21:05:01 +08:00
|
|
|
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
|
2026-06-02 02:58:01 +08:00
|
|
|
import { useEffect, useState } from "react";
|
2026-06-02 00:32:46 +08:00
|
|
|
import { useI18n } from "../i18n";
|
2026-06-02 21:05:01 +08:00
|
|
|
|
2026-06-02 21:10:58 +08:00
|
|
|
import { openWalletDeepLink } from "./deepLinks";
|
2026-06-02 21:05:01 +08:00
|
|
|
import { getInjectedWallet, type WalletKind } from "./injected";
|
2026-06-02 00:32:46 +08:00
|
|
|
import { useWallet } from "./WalletProvider";
|
2026-06-02 03:43:13 +08:00
|
|
|
import { useWalletConnectLogin } from "./useWalletConnectLogin";
|
|
|
|
|
import { WalletBrandIcon } from "./WalletBrandIcon";
|
2026-06-02 00:32:46 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
type ModalState = "idle" | "signing";
|
|
|
|
|
type Step = "wallet" | "method";
|
2026-06-02 02:58:01 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
|
2026-06-02 02:58:01 +08:00
|
|
|
|
|
|
|
|
function isMobileDevice(): boolean {
|
|
|
|
|
if (typeof navigator === "undefined") return false;
|
|
|
|
|
const ua = navigator.userAgent || "";
|
2026-06-02 03:43:13 +08:00
|
|
|
if (
|
2026-06-02 02:58:01 +08:00
|
|
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
|
|
|
|
ua,
|
2026-06-02 03:43:13 +08:00
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
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;
|
2026-06-02 02:58:01 +08:00
|
|
|
}
|
2026-06-02 00:32:46 +08:00
|
|
|
|
|
|
|
|
export function WalletLoginModal() {
|
|
|
|
|
const { t } = useI18n();
|
2026-06-02 21:05:01 +08:00
|
|
|
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
|
2026-06-02 03:43:13 +08:00
|
|
|
const wc = useWalletConnectLogin();
|
2026-06-02 21:05:01 +08:00
|
|
|
const [step, setStep] = useState<Step>("wallet");
|
|
|
|
|
const [selected, setSelected] = useState<WalletKind | null>(null);
|
2026-06-02 00:32:46 +08:00
|
|
|
const [state, setState] = useState<ModalState>("idle");
|
|
|
|
|
const [error, setError] = useState("");
|
2026-06-02 02:58:01 +08:00
|
|
|
const [mobileDevice, setMobileDevice] = useState(false);
|
2026-06-02 21:05:01 +08:00
|
|
|
|
|
|
|
|
const resetWalletConnect = wc.reset;
|
2026-06-02 02:58:01 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!loginModalOpen) return;
|
|
|
|
|
setMobileDevice(isMobileDevice());
|
2026-06-02 21:05:01 +08:00
|
|
|
setStep("wallet");
|
|
|
|
|
setSelected(null);
|
2026-06-02 03:43:13 +08:00
|
|
|
setState("idle");
|
2026-06-02 02:58:01 +08:00
|
|
|
setError("");
|
2026-06-02 21:05:01 +08:00
|
|
|
resetWalletConnect();
|
|
|
|
|
}, [loginModalOpen, resetWalletConnect]);
|
2026-06-02 03:43:13 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const busy = state === "signing";
|
2026-06-02 00:32:46 +08:00
|
|
|
|
|
|
|
|
const close = () => {
|
2026-06-02 03:43:13 +08:00
|
|
|
if (busy) return;
|
2026-06-02 00:57:37 +08:00
|
|
|
closeLoginModal();
|
2026-06-02 00:32:46 +08:00
|
|
|
setError("");
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:57:37 +08:00
|
|
|
if (!loginModalOpen) return null;
|
2026-06-02 00:32:46 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const walletName = (kind: WalletKind) => 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;
|
2026-06-02 00:32:46 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const resetFlow = () => {
|
2026-06-02 00:32:46 +08:00
|
|
|
setError("");
|
2026-06-02 21:05:01 +08:00
|
|
|
setState("idle");
|
|
|
|
|
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)),
|
|
|
|
|
);
|
2026-06-02 04:00:30 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-02 02:58:01 +08:00
|
|
|
setState("signing");
|
2026-06-02 21:05:01 +08:00
|
|
|
await signInInjected(kind)
|
2026-06-02 02:58:01 +08:00
|
|
|
.catch((err) => {
|
|
|
|
|
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
|
|
|
|
|
})
|
|
|
|
|
.finally(() => setState("idle"));
|
2026-06-02 00:32:46 +08:00
|
|
|
};
|
|
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const chooseQr = (kind: WalletKind) => {
|
2026-06-02 03:43:13 +08:00
|
|
|
setError("");
|
2026-06-02 21:05:01 +08:00
|
|
|
void wc.start(kind);
|
2026-06-02 03:43:13 +08:00
|
|
|
};
|
|
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
const iconButtonClass =
|
|
|
|
|
"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";
|
2026-06-02 03:43:13 +08:00
|
|
|
|
2026-06-02 00:32:46 +08:00
|
|
|
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"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="wallet-login-title"
|
|
|
|
|
>
|
2026-06-02 21:05:01 +08:00
|
|
|
<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-3">
|
|
|
|
|
<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
|
|
|
|
|
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>
|
2026-06-02 00:32:46 +08:00
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={close}
|
2026-06-02 21:05:01 +08:00
|
|
|
aria-label={t("close")}
|
|
|
|
|
className={iconButtonClass}
|
2026-06-02 00:32:46 +08:00
|
|
|
>
|
2026-06-02 21:05:01 +08:00
|
|
|
<X size={18} />
|
2026-06-02 00:32:46 +08:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
{/* Step 1 — choose a wallet. */}
|
|
|
|
|
{step === "wallet" ? (
|
|
|
|
|
<div className="mt-5 grid gap-2">
|
|
|
|
|
{wallets.map((kind) => (
|
2026-06-02 03:43:13 +08:00
|
|
|
<button
|
2026-06-02 21:05:01 +08:00
|
|
|
key={kind}
|
2026-06-02 03:43:13 +08:00
|
|
|
type="button"
|
2026-06-02 21:05:01 +08:00
|
|
|
onClick={() => pickWallet(kind)}
|
|
|
|
|
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"
|
2026-06-02 03:43:13 +08:00
|
|
|
>
|
2026-06-02 21:05:01 +08:00
|
|
|
<WalletBrandIcon kind={kind} size={32} />
|
|
|
|
|
<span className="flex-1 text-base font-semibold text-neutral-100">
|
|
|
|
|
{walletName(kind)}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronRight size={18} className="text-neutral-500" />
|
2026-06-02 03:43:13 +08:00
|
|
|
</button>
|
2026-06-02 21:05:01 +08:00
|
|
|
))}
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{!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}
|
|
|
|
|
|
2026-06-02 21:10:58 +08:00
|
|
|
{mobileDevice ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => openWalletDeepLink(selected)}
|
|
|
|
|
className="flex flex-col items-start gap-1 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-base font-semibold text-neutral-100">
|
|
|
|
|
{t("walletOpenWalletApp")}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs leading-5 text-neutral-400">
|
|
|
|
|
{t("walletOpenWalletAppDesc")}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* Method: browser wallet (injected). Hidden on normal mobile browsers,
|
|
|
|
|
because only wallet in-app browsers expose an injected wallet. */}
|
2026-06-02 21:05:01 +08:00
|
|
|
{(() => {
|
|
|
|
|
const ok = injectedAvailable(selected);
|
2026-06-02 21:10:58 +08:00
|
|
|
if (mobileDevice) return null;
|
2026-06-02 21:05:01 +08:00
|
|
|
return (
|
2026-06-02 02:58:01 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-06-02 21:05:01 +08:00
|
|
|
onClick={() => void signInjectedFor(selected)}
|
|
|
|
|
disabled={!ok || 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"
|
|
|
|
|
}`}
|
2026-06-02 02:58:01 +08:00
|
|
|
>
|
2026-06-02 21:05:01 +08:00
|
|
|
<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>
|
2026-06-02 02:58:01 +08:00
|
|
|
</button>
|
2026-06-02 21:05:01 +08:00
|
|
|
);
|
|
|
|
|
})()}
|
2026-06-02 03:43:13 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
{/* Method: scan to log in. */}
|
|
|
|
|
{(() => {
|
|
|
|
|
const ok = qrAvailable();
|
|
|
|
|
const isTp = selected === "tokenPocket";
|
|
|
|
|
const qrBusy = wc.state !== "idle";
|
|
|
|
|
const qrLabel =
|
|
|
|
|
wc.state === "connecting"
|
|
|
|
|
? t("walletConnecting")
|
|
|
|
|
: wc.state === "signing"
|
|
|
|
|
? t("walletSigning")
|
2026-06-02 21:10:58 +08:00
|
|
|
: t("walletQrLogin");
|
2026-06-02 21:05:01 +08:00
|
|
|
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>
|
2026-06-02 03:43:13 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
{ok ? (
|
|
|
|
|
<p className="text-xs leading-5 text-amber-300/80">
|
|
|
|
|
{t("walletNetworkWarning")}
|
2026-06-02 04:00:30 +08:00
|
|
|
</p>
|
2026-06-02 21:05:01 +08:00
|
|
|
) : null}
|
2026-06-02 03:43:13 +08:00
|
|
|
|
2026-06-02 21:05:01 +08:00
|
|
|
{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")}
|
2026-06-02 03:43:13 +08:00
|
|
|
</p>
|
2026-06-02 21:05:01 +08:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-02 03:43:13 +08:00
|
|
|
</div>
|
2026-06-02 21:05:01 +08:00
|
|
|
);
|
|
|
|
|
})()}
|
2026-06-02 00:32:46 +08:00
|
|
|
</div>
|
2026-06-02 21:05:01 +08:00
|
|
|
) : null}
|
2026-06-02 00:32:46 +08:00
|
|
|
|
2026-06-02 03:43:13 +08:00
|
|
|
{error || wc.error ? (
|
2026-06-02 00:32:46 +08:00
|
|
|
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
2026-06-02 03:43:13 +08:00
|
|
|
{error || wc.error}
|
2026-06-02 00:32:46 +08:00
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-02 03:43:13 +08:00
|
|
|
|
|
|
|
|
function walletNameKey(kind: WalletKind): string {
|
|
|
|
|
if (kind === "tokenPocket") return "walletTokenPocket";
|
|
|
|
|
if (kind === "metaMask") return "walletMetaMask";
|
|
|
|
|
return "walletImToken";
|
|
|
|
|
}
|