terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
12 changed files with 584 additions and 0 deletions
Showing only changes of commit 43700d9fdc - Show all commits

View File

@@ -4,6 +4,7 @@ import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
import { WalletLoginModal } from "./wallet/WalletLoginModal";
import { WalletProvider } from "./wallet/WalletProvider"; import { WalletProvider } from "./wallet/WalletProvider";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { LocalizedHomePage } from "./pages/LocalizedHome"; import { LocalizedHomePage } from "./pages/LocalizedHome";
@@ -37,6 +38,7 @@ export default function App() {
<ImageLightboxProvider> <ImageLightboxProvider>
<VideoPlayerProvider> <VideoPlayerProvider>
<PageTitleProvider> <PageTitleProvider>
<WalletLoginModal />
<BrowserRouter> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<Routes> <Routes>

View File

@@ -19,6 +19,7 @@ import {
stripLangPrefix, stripLangPrefix,
} from "../languageRoutes"; } from "../languageRoutes";
import { useLocalizedPath } from "../useLocalizedPath"; import { useLocalizedPath } from "../useLocalizedPath";
import { WalletButton } from "../wallet/WalletButton";
type PublicNavWhich = type PublicNavWhich =
| "home" | "home"
@@ -657,6 +658,9 @@ export function PublicLayout() {
ariaLabel={t("langLabel")} ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40" className="hidden h-10 w-36 md:block lg:w-40"
/> />
<div className="hidden md:block">
<WalletButton />
</div>
<button <button
ref={desktopMenuButtonRef} ref={desktopMenuButtonRef}
type="button" type="button"
@@ -718,6 +722,9 @@ export function PublicLayout() {
> >
{t("popular")} {t("popular")}
</Link> </Link>
<div className="mt-2 w-full max-w-xs">
<WalletButton compact />
</div>
</div> </div>
) : null} ) : null}
</header> </header>

View File

@@ -143,6 +143,33 @@ export const enDict: Dict = {
favoritesComingSoon: "Coming Soon", favoritesComingSoon: "Coming Soon",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Sign-in and favorites are in development. Stay tuned.", "Sign-in and favorites are in development. Stay tuned.",
close: "Close",
walletConnect: "Connect Wallet",
walletConnectedAs: "Connected wallet",
walletDisconnect: "Disconnect",
walletLoginTitle: "Connect wallet",
walletLoginDesc:
"Sign a message to verify your wallet address. No transaction or gas fee.",
walletInjected: "Browser wallet / DApp browser",
walletInjectedDesc: "Use the wallet already available in this browser.",
walletTokenPocketQr: "TokenPocket QR login",
walletTokenPocketQrDesc:
"Recommended for China users. Scan with TokenPocket and sign to return login to this browser.",
walletGenerateQr: "Generate QR",
walletQrUseAnotherDevice: "Scan with TokenPocket on another device.",
walletOpenTokenPocket: "Open TokenPocket",
walletOpenMetaMask: "Open MetaMask",
walletOpenImToken: "Open imToken",
walletRainbowFallback: "MetaMask / imToken QR fallback",
walletRainbowFallbackDesc:
"Use RainbowKit/Reown scan login if you need QR for MetaMask or imToken.",
walletOpenRainbow: "Open QR login",
walletNetworkWarning:
"This fallback uses WalletConnect/Reown and may be unstable on some China networks. If it fails, open this site inside your wallet app.",
walletSigning: "Signing…",
walletTpExpired: "TokenPocket QR expired. Please generate a new one.",
walletTpQrFailed: "Could not generate TokenPocket QR.",
walletRainbowUnavailable: "QR login is not available yet.",
walletLoginSuccess: "Wallet connected", walletLoginSuccess: "Wallet connected",
walletLoginFailed: "Wallet login failed", walletLoginFailed: "Wallet login failed",
walletDisconnected: "Wallet disconnected", walletDisconnected: "Wallet disconnected",

View File

@@ -143,6 +143,33 @@ export const idDict: Dict = {
favoritesComingSoon: "Segera Hadir", favoritesComingSoon: "Segera Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.", "Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
close: "Tutup",
walletConnect: "Hubungkan Dompet",
walletConnectedAs: "Dompet terhubung",
walletDisconnect: "Putuskan",
walletLoginTitle: "Hubungkan dompet",
walletLoginDesc:
"Tanda tangani pesan untuk memverifikasi alamat dompet. Tidak ada transaksi atau gas.",
walletInjected: "Dompet browser / browser DApp",
walletInjectedDesc: "Gunakan dompet yang tersedia di browser ini.",
walletTokenPocketQr: "Login QR TokenPocket",
walletTokenPocketQrDesc:
"Direkomendasikan untuk pengguna Tiongkok. Pindai dengan TokenPocket dan tanda tangani untuk login di browser ini.",
walletGenerateQr: "Buat QR",
walletQrUseAnotherDevice: "Pindai dengan TokenPocket di perangkat lain.",
walletOpenTokenPocket: "Buka TokenPocket",
walletOpenMetaMask: "Buka MetaMask",
walletOpenImToken: "Buka imToken",
walletRainbowFallback: "Fallback QR MetaMask / imToken",
walletRainbowFallbackDesc:
"Gunakan RainbowKit/Reown jika butuh QR untuk MetaMask atau imToken.",
walletOpenRainbow: "Buka login QR",
walletNetworkWarning:
"Fallback ini memakai WalletConnect/Reown dan mungkin tidak stabil di beberapa jaringan Tiongkok. Jika gagal, buka situs ini di browser DApp dompet.",
walletSigning: "Menandatangani…",
walletTpExpired: "QR TokenPocket kedaluwarsa. Buat yang baru.",
walletTpQrFailed: "Tidak dapat membuat QR TokenPocket.",
walletRainbowUnavailable: "Login QR belum tersedia.",
walletLoginSuccess: "Dompet terhubung", walletLoginSuccess: "Dompet terhubung",
walletLoginFailed: "Login dompet gagal", walletLoginFailed: "Login dompet gagal",
walletDisconnected: "Dompet terputus", walletDisconnected: "Dompet terputus",

View File

@@ -143,6 +143,34 @@ export const jaDict: Dict = {
favorites: "お気に入り", favorites: "お気に入り",
favoritesComingSoon: "近日公開", favoritesComingSoon: "近日公開",
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
close: "閉じる",
walletConnect: "ウォレット接続",
walletConnectedAs: "接続中のウォレット",
walletDisconnect: "切断",
walletLoginTitle: "ウォレットを接続",
walletLoginDesc:
"メッセージ署名でウォレットアドレスを確認します。取引やガス代は発生しません。",
walletInjected: "ブラウザウォレット / DApp ブラウザ",
walletInjectedDesc: "このブラウザで利用可能なウォレットを使います。",
walletTokenPocketQr: "TokenPocket QR ログイン",
walletTokenPocketQrDesc:
"中国ユーザーに推奨。TokenPocket でスキャンして署名すると、このブラウザでログインが完了します。",
walletGenerateQr: "QR を生成",
walletQrUseAnotherDevice: "別の端末の TokenPocket でスキャンしてください。",
walletOpenTokenPocket: "TokenPocket を開く",
walletOpenMetaMask: "MetaMask を開く",
walletOpenImToken: "imToken を開く",
walletRainbowFallback: "MetaMask / imToken QR 予備",
walletRainbowFallbackDesc:
"MetaMask または imToken の QR が必要な場合は RainbowKit/Reown 接続を使います。",
walletOpenRainbow: "QR ログインを開く",
walletNetworkWarning:
"この予備方式は WalletConnect/Reown に依存するため、中国の一部ネットワークでは不安定な場合があります。失敗した場合はウォレット内蔵ブラウザで開いてください。",
walletSigning: "署名中…",
walletTpExpired:
"TokenPocket QR の有効期限が切れました。再生成してください。",
walletTpQrFailed: "TokenPocket QR を生成できませんでした。",
walletRainbowUnavailable: "QR ログインは現在利用できません。",
walletLoginSuccess: "ウォレットを接続しました", walletLoginSuccess: "ウォレットを接続しました",
walletLoginFailed: "ウォレットログインに失敗しました", walletLoginFailed: "ウォレットログインに失敗しました",
walletDisconnected: "ウォレットを切断しました", walletDisconnected: "ウォレットを切断しました",

View File

@@ -143,6 +143,33 @@ export const koDict: Dict = {
favoritesComingSoon: "출시 예정", favoritesComingSoon: "출시 예정",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.", "로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
close: "닫기",
walletConnect: "지갑 연결",
walletConnectedAs: "연결된 지갑",
walletDisconnect: "연결 해제",
walletLoginTitle: "지갑 연결",
walletLoginDesc:
"메시지 서명으로 지갑 주소를 확인합니다. 트랜잭션이나 가스 수수료는 없습니다.",
walletInjected: "브라우저 지갑 / DApp 브라우저",
walletInjectedDesc: "현재 브라우저에서 사용 가능한 지갑을 사용합니다.",
walletTokenPocketQr: "TokenPocket QR 로그인",
walletTokenPocketQrDesc:
"중국 사용자에게 권장됩니다. TokenPocket으로 스캔하고 서명하면 이 브라우저에서 로그인이 완료됩니다.",
walletGenerateQr: "QR 생성",
walletQrUseAnotherDevice: "다른 기기의 TokenPocket으로 스캔하세요.",
walletOpenTokenPocket: "TokenPocket 열기",
walletOpenMetaMask: "MetaMask 열기",
walletOpenImToken: "imToken 열기",
walletRainbowFallback: "MetaMask / imToken QR 대체",
walletRainbowFallbackDesc:
"MetaMask 또는 imToken QR이 필요하면 RainbowKit/Reown 연결을 사용하세요.",
walletOpenRainbow: "QR 로그인 열기",
walletNetworkWarning:
"이 대체 방식은 WalletConnect/Reown을 사용하므로 일부 중국 네트워크에서 불안정할 수 있습니다. 실패하면 지갑 DApp 브라우저에서 사이트를 여세요.",
walletSigning: "서명 중…",
walletTpExpired: "TokenPocket QR이 만료되었습니다. 새로 생성하세요.",
walletTpQrFailed: "TokenPocket QR을 생성할 수 없습니다.",
walletRainbowUnavailable: "QR 로그인을 사용할 수 없습니다.",
walletLoginSuccess: "지갑이 연결되었습니다", walletLoginSuccess: "지갑이 연결되었습니다",
walletLoginFailed: "지갑 로그인에 실패했습니다", walletLoginFailed: "지갑 로그인에 실패했습니다",
walletDisconnected: "지갑 연결이 해제되었습니다", walletDisconnected: "지갑 연결이 해제되었습니다",

View File

@@ -143,6 +143,33 @@ export const msDict: Dict = {
favoritesComingSoon: "Akan Hadir", favoritesComingSoon: "Akan Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.", "Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
close: "Tutup",
walletConnect: "Sambung Dompet",
walletConnectedAs: "Dompet disambungkan",
walletDisconnect: "Putuskan",
walletLoginTitle: "Sambung dompet",
walletLoginDesc:
"Tandatangani mesej untuk mengesahkan alamat dompet. Tiada transaksi atau gas.",
walletInjected: "Dompet pelayar / pelayar DApp",
walletInjectedDesc: "Gunakan dompet yang tersedia dalam pelayar ini.",
walletTokenPocketQr: "Log masuk QR TokenPocket",
walletTokenPocketQrDesc:
"Disyorkan untuk pengguna China. Imbas dengan TokenPocket dan tandatangani untuk log masuk pada pelayar ini.",
walletGenerateQr: "Jana QR",
walletQrUseAnotherDevice: "Imbas dengan TokenPocket pada peranti lain.",
walletOpenTokenPocket: "Buka TokenPocket",
walletOpenMetaMask: "Buka MetaMask",
walletOpenImToken: "Buka imToken",
walletRainbowFallback: "Sandaran QR MetaMask / imToken",
walletRainbowFallbackDesc:
"Gunakan RainbowKit/Reown jika perlu QR untuk MetaMask atau imToken.",
walletOpenRainbow: "Buka log masuk QR",
walletNetworkWarning:
"Kaedah sandaran ini menggunakan WalletConnect/Reown dan mungkin tidak stabil pada sesetengah rangkaian China. Jika gagal, buka laman ini dalam pelayar DApp dompet.",
walletSigning: "Menandatangani…",
walletTpExpired: "QR TokenPocket tamat tempoh. Sila jana semula.",
walletTpQrFailed: "Tidak dapat menjana QR TokenPocket.",
walletRainbowUnavailable: "Log masuk QR belum tersedia.",
walletLoginSuccess: "Dompet disambungkan", walletLoginSuccess: "Dompet disambungkan",
walletLoginFailed: "Log masuk dompet gagal", walletLoginFailed: "Log masuk dompet gagal",
walletDisconnected: "Dompet diputuskan", walletDisconnected: "Dompet diputuskan",

View File

@@ -143,6 +143,33 @@ export const viDict: Dict = {
favoritesComingSoon: "Sắp ra mắt", favoritesComingSoon: "Sắp ra mắt",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.", "Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
close: "Đóng",
walletConnect: "Kết nối ví",
walletConnectedAs: "Ví đã kết nối",
walletDisconnect: "Ngắt kết nối",
walletLoginTitle: "Kết nối ví",
walletLoginDesc:
"Ký tin nhắn để xác minh địa chỉ ví. Không có giao dịch hay phí gas.",
walletInjected: "Ví trình duyệt / trình duyệt DApp",
walletInjectedDesc: "Dùng ví đã có trong trình duyệt hiện tại.",
walletTokenPocketQr: "Đăng nhập QR TokenPocket",
walletTokenPocketQrDesc:
"Khuyến nghị cho người dùng Trung Quốc. Quét bằng TokenPocket và ký để đăng nhập trên trình duyệt này.",
walletGenerateQr: "Tạo QR",
walletQrUseAnotherDevice: "Quét bằng TokenPocket trên thiết bị khác.",
walletOpenTokenPocket: "Mở TokenPocket",
walletOpenMetaMask: "Mở MetaMask",
walletOpenImToken: "Mở imToken",
walletRainbowFallback: "QR dự phòng MetaMask / imToken",
walletRainbowFallbackDesc:
"Dùng RainbowKit/Reown nếu cần QR cho MetaMask hoặc imToken.",
walletOpenRainbow: "Mở đăng nhập QR",
walletNetworkWarning:
"Cách dự phòng này dùng WalletConnect/Reown và có thể không ổn định trên một số mạng ở Trung Quốc. Nếu lỗi, hãy mở trang trong trình duyệt DApp của ví.",
walletSigning: "Đang ký…",
walletTpExpired: "QR TokenPocket đã hết hạn. Vui lòng tạo lại.",
walletTpQrFailed: "Không thể tạo QR TokenPocket.",
walletRainbowUnavailable: "Đăng nhập QR chưa khả dụng.",
walletLoginSuccess: "Đã kết nối ví", walletLoginSuccess: "Đã kết nối ví",
walletLoginFailed: "Đăng nhập ví thất bại", walletLoginFailed: "Đăng nhập ví thất bại",
walletDisconnected: "Đã ngắt kết nối ví", walletDisconnected: "Đã ngắt kết nối ví",

View File

@@ -140,6 +140,32 @@ export const zhDict: Dict = {
favorites: "我的收藏", favorites: "我的收藏",
favoritesComingSoon: "功能即将推出", favoritesComingSoon: "功能即将推出",
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
close: "关闭",
walletConnect: "连接钱包",
walletConnectedAs: "已连接钱包",
walletDisconnect: "断开连接",
walletLoginTitle: "连接钱包",
walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。",
walletInjected: "浏览器钱包 / 钱包内置浏览器",
walletInjectedDesc: "使用当前浏览器里已经注入的钱包。",
walletTokenPocketQr: "TokenPocket 扫码登录",
walletTokenPocketQrDesc:
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",
walletGenerateQr: "生成二维码",
walletQrUseAnotherDevice: "请用另一台设备上的 TokenPocket 扫码。",
walletOpenTokenPocket: "打开 TokenPocket",
walletOpenMetaMask: "打开 MetaMask",
walletOpenImToken: "打开 imToken",
walletRainbowFallback: "MetaMask / imToken 扫码备用",
walletRainbowFallbackDesc:
"如果需要 MetaMask 或 imToken 扫码,可使用 RainbowKit/Reown 连接。",
walletOpenRainbow: "打开扫码登录",
walletNetworkWarning:
"此备用方式依赖 WalletConnect/Reown在部分中国网络可能不稳定。失败时请用钱包内置浏览器打开本站。",
walletSigning: "签名中…",
walletTpExpired: "TokenPocket 二维码已过期,请重新生成。",
walletTpQrFailed: "无法生成 TokenPocket 二维码。",
walletRainbowUnavailable: "扫码登录暂不可用。",
walletLoginSuccess: "钱包已连接", walletLoginSuccess: "钱包已连接",
walletLoginFailed: "钱包登录失败", walletLoginFailed: "钱包登录失败",
walletDisconnected: "钱包已断开", walletDisconnected: "钱包已断开",

View 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>
);
}

View 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
View 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);
}