feat: add wallet login modal
This commit is contained in:
78
src/wallet/WalletButton.tsx
Normal file
78
src/wallet/WalletButton.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { shortenAddress, useWallet } from "./WalletProvider";
|
||||
|
||||
export function WalletButton({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const wallet = useWallet();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutside);
|
||||
document.addEventListener("touchstart", closeOnOutside);
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutside);
|
||||
document.removeEventListener("touchstart", closeOnOutside);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (wallet.status === "loggedIn" && wallet.address) {
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={[
|
||||
"inline-flex h-10 items-center justify-center rounded-full border border-ark-gold/45 bg-ark-gold/10 px-3 text-sm font-semibold text-ark-gold2 outline-none transition hover:bg-ark-gold/15 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
|
||||
compact ? "w-full" : "",
|
||||
].join(" ")}
|
||||
aria-label={t("walletConnectedAs")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="mr-2 h-2 w-2 rounded-full bg-emerald-400" />
|
||||
{shortenAddress(wallet.address)}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-50 w-52 rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-2 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl">
|
||||
<div className="truncate px-3 py-2 text-xs text-neutral-400">
|
||||
{wallet.address}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
wallet.logout();
|
||||
}}
|
||||
className="w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-red-200 transition hover:bg-red-500/10"
|
||||
>
|
||||
{t("walletDisconnect")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={wallet.openLoginModal}
|
||||
className={[
|
||||
"inline-flex h-10 items-center justify-center rounded-full border border-ark-gold/50 bg-ark-gold px-4 text-sm font-bold text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
|
||||
compact ? "w-full" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{wallet.status === "loading" ? t("loading") : t("walletConnect")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
279
src/wallet/WalletLoginModal.tsx
Normal file
279
src/wallet/WalletLoginModal.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useConnectModal } from "@rainbow-me/rainbowkit";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAccount, useSignMessage } from "wagmi";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
createTokenPocketLoginRequest,
|
||||
fetchTokenPocketLoginResult,
|
||||
requestWalletNonce,
|
||||
verifyWalletSignature,
|
||||
type TokenPocketLoginRequest,
|
||||
} from "./api";
|
||||
import { openWalletDeepLink } from "./deepLinks";
|
||||
import { useWallet } from "./WalletProvider";
|
||||
|
||||
const pollIntervalMs = 1800;
|
||||
|
||||
type ModalState = "idle" | "tpLoading" | "tpPolling" | "rainbowSigning";
|
||||
|
||||
export function WalletLoginModal() {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const wallet = useWallet();
|
||||
const { openConnectModal } = useConnectModal();
|
||||
const { address, isConnected } = useAccount();
|
||||
const { signMessageAsync } = useSignMessage();
|
||||
const [state, setState] = useState<ModalState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
|
||||
null,
|
||||
);
|
||||
const [rainbowPending, setRainbowPending] = useState(false);
|
||||
const rainbowSigningRef = useRef(false);
|
||||
|
||||
const close = () => {
|
||||
if (state === "tpLoading" || state === "rainbowSigning") return;
|
||||
wallet.closeLoginModal();
|
||||
setError("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!wallet.loginModalOpen || !tpRequest) return;
|
||||
if (state !== "tpPolling") return;
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await fetchTokenPocketLoginResult(tpRequest.actionId);
|
||||
if (cancelled) return;
|
||||
if (result.status === "completed") {
|
||||
const verified = await verifyWalletSignature({
|
||||
address: result.address,
|
||||
message: result.message,
|
||||
signature: result.signature,
|
||||
});
|
||||
if (cancelled) return;
|
||||
wallet.completeLogin(verified.token, verified.wallet);
|
||||
showToast(t("walletLoginSuccess"));
|
||||
setState("idle");
|
||||
setTpRequest(null);
|
||||
return;
|
||||
}
|
||||
if (result.status === "expired" || result.status === "failed") {
|
||||
setState("idle");
|
||||
setError(result.error || t("walletTpExpired"));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t("walletLoginFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), pollIntervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [state, tpRequest, t, showToast, wallet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rainbowPending || !isConnected || !address) return;
|
||||
if (rainbowSigningRef.current) return;
|
||||
rainbowSigningRef.current = true;
|
||||
setState("rainbowSigning");
|
||||
setError("");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const nonce = await requestWalletNonce(address);
|
||||
const signature = await signMessageAsync({ message: nonce.message });
|
||||
const verified = await verifyWalletSignature({
|
||||
address,
|
||||
message: nonce.message,
|
||||
signature,
|
||||
});
|
||||
wallet.completeLogin(verified.token, verified.wallet);
|
||||
showToast(t("walletLoginSuccess"));
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("walletLoginFailed");
|
||||
setError(message || t("walletLoginFailed"));
|
||||
showToast(t("walletLoginFailed"), "error");
|
||||
} finally {
|
||||
setState("idle");
|
||||
setRainbowPending(false);
|
||||
rainbowSigningRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
address,
|
||||
isConnected,
|
||||
rainbowPending,
|
||||
showToast,
|
||||
signMessageAsync,
|
||||
t,
|
||||
wallet,
|
||||
]);
|
||||
|
||||
if (!wallet.loginModalOpen) return null;
|
||||
|
||||
const startInjected = async () => {
|
||||
setError("");
|
||||
setState("idle");
|
||||
await wallet.signInInjected().catch(() => undefined);
|
||||
};
|
||||
|
||||
const startTokenPocketQr = async () => {
|
||||
setError("");
|
||||
setState("tpLoading");
|
||||
try {
|
||||
const req = await createTokenPocketLoginRequest();
|
||||
setTpRequest(req);
|
||||
setState("tpPolling");
|
||||
} catch {
|
||||
setState("idle");
|
||||
setError(t("walletTpQrFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const startRainbowFallback = () => {
|
||||
setError("");
|
||||
setRainbowPending(true);
|
||||
openConnectModal?.();
|
||||
if (!openConnectModal) setError(t("walletRainbowUnavailable"));
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<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="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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">
|
||||
{t("walletLoginDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{t("close")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startInjected()}
|
||||
className="rounded-2xl border border-ark-gold/40 bg-ark-gold/10 p-4 text-left transition hover:bg-ark-gold/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
|
||||
>
|
||||
<span className="block font-semibold text-ark-gold2">
|
||||
{t("walletInjected")}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-neutral-400">
|
||||
{t("walletInjectedDesc")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{t("walletTokenPocketQr")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-neutral-400">
|
||||
{t("walletTokenPocketQrDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startTokenPocketQr()}
|
||||
disabled={state === "tpLoading"}
|
||||
className="shrink-0 rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{state === "tpLoading" ? t("loading") : t("walletGenerateQr")}
|
||||
</button>
|
||||
</div>
|
||||
{tpRequest ? (
|
||||
<div className="mt-4 grid place-items-center gap-3 rounded-2xl bg-white p-4 text-center">
|
||||
<QRCodeSVG value={tpRequest.qrUrl} size={196} level="M" />
|
||||
<p className="text-xs font-medium text-neutral-700">
|
||||
{t("walletQrUseAnotherDevice")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWalletDeepLink("tokenPocket")}
|
||||
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
|
||||
>
|
||||
{t("walletOpenTokenPocket")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWalletDeepLink("metaMask")}
|
||||
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
|
||||
>
|
||||
{t("walletOpenMetaMask")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWalletDeepLink("imToken")}
|
||||
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
|
||||
>
|
||||
{t("walletOpenImToken")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-white/15 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{t("walletRainbowFallback")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-neutral-400">
|
||||
{t("walletRainbowFallbackDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startRainbowFallback}
|
||||
disabled={state === "rainbowSigning"}
|
||||
className="shrink-0 rounded-full border border-ark-gold/50 px-4 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{state === "rainbowSigning"
|
||||
? t("walletSigning")
|
||||
: t("walletOpenRainbow")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-yellow-200/80">
|
||||
{t("walletNetworkWarning")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/wallet/deepLinks.ts
Normal file
29
src/wallet/deepLinks.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
||||
|
||||
function currentDappUrl(): string {
|
||||
if (typeof window === "undefined") return "https://ark-library.com";
|
||||
return window.location.href;
|
||||
}
|
||||
|
||||
export function walletDeepLink(
|
||||
kind: WalletKind,
|
||||
dappUrl = currentDappUrl(),
|
||||
): string {
|
||||
switch (kind) {
|
||||
case "tokenPocket":
|
||||
return `tpdapp://open?params=${encodeURIComponent(
|
||||
JSON.stringify({ url: dappUrl, chain: "ETH" }),
|
||||
)}`;
|
||||
case "metaMask":
|
||||
return `https://metamask.app.link/dapp/${dappUrl.replace(/^https?:\/\//, "")}`;
|
||||
case "imToken":
|
||||
return `imtokenv2://navigate/DappView?url=${encodeURIComponent(dappUrl)}`;
|
||||
default:
|
||||
return dappUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function openWalletDeepLink(kind: WalletKind): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.location.href = walletDeepLink(kind);
|
||||
}
|
||||
Reference in New Issue
Block a user