fix: TokenPocket mobile deep-link login, desktop empty-state, toast above modal

- Mobile TokenPocket now opens the tpoutside:// sign deep link and returns to
  the original browser to finish login (no wallet in-app browser); desktop
  keeps the QR. Fixes mobile login + logout being trapped in TP's browser.
- Desktop without an injected wallet shows a clear message instead of a dead
  button; TokenPocket login card is always available as a working path.
- Raise toast z-index above the login modal so feedback is visible.
- Add native TokenPocket-login strings across 7 locales.
- Document that the live backend lacks favorites + TokenPocket routes (404),
  the real blocker for those features in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TerryM
2026-06-02 04:00:30 +08:00
parent 7abe4a868c
commit ed04e1fb7e
10 changed files with 204 additions and 83 deletions

View File

@@ -61,7 +61,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[100] flex flex-col items-center gap-2 px-4 md:bottom-6"
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[200] flex flex-col items-center gap-2 px-4 md:bottom-6"
aria-live="polite"
aria-atomic="true"
>

View File

@@ -183,8 +183,15 @@ export const enDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "TokenPocket login",
walletTpMobileDesc:
"Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.",
walletTpLoginBtn: "Log in with TokenPocket",
walletTpWaiting: "Waiting for your signature in TokenPocket…",
walletTpReopen: "Reopen TokenPocket",
favoritesFilters: "Filters",
favoriteSessionExpired: "Your session expired. Please sign in again.",
loadFailed: "Could not load your favorites.",
walletChooseDesktop:
"Choose the wallet you want to use. On desktop, install the matching browser extension.",
walletChooseMobile: "Choose a wallet app to open this site.",

View File

@@ -183,8 +183,15 @@ export const idDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "Masuk TokenPocket",
walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.",
walletTpLoginBtn: "Masuk dengan TokenPocket",
walletTpWaiting: "Menunggu tanda tangan Anda di TokenPocket…",
walletTpReopen: "Buka kembali TokenPocket",
favoritesFilters: "Filter",
favoriteSessionExpired: "Sesi Anda telah berakhir. Silakan masuk lagi.",
loadFailed: "Gagal memuat favorit Anda.",
walletChooseDesktop:
"Pilih dompet yang ingin digunakan. Di desktop, pasang ekstensi browser yang sesuai.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka situs ini.",

View File

@@ -203,9 +203,16 @@ export const jaDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "TokenPocket ログイン",
walletTpMobileDesc:
"TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。",
walletTpLoginBtn: "TokenPocket でログイン",
walletTpWaiting: "TokenPocket での署名を待っています…",
walletTpReopen: "TokenPocket を再度開く",
favoritesFilters: "フィルター",
favoriteSessionExpired:
"セッションの有効期限が切れました。もう一度ログインしてください。",
loadFailed: "お気に入りを読み込めませんでした。",
walletChooseDesktop:
"使用するウォレットを選択してください。デスクトップの場合は対応するブラウザ拡張機能をインストールしてください。",
walletChooseMobile: "このサイトを開くウォレットアプリを選択してください。",

View File

@@ -181,8 +181,15 @@ export const koDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "TokenPocket 로그인",
walletTpMobileDesc:
"TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.",
walletTpLoginBtn: "TokenPocket으로 로그인",
walletTpWaiting: "TokenPocket에서 서명을 기다리는 중…",
walletTpReopen: "TokenPocket 다시 열기",
favoritesFilters: "필터",
favoriteSessionExpired: "세션이 만료되었습니다. 다시 로그인해 주세요.",
loadFailed: "즐겨찾기를 불러오지 못했습니다.",
walletChooseDesktop:
"사용할 지갑을 선택하세요. 데스크톱에서는 해당 브라우저 확장 프로그램을 설치하세요.",
walletChooseMobile: "이 사이트를 열 지갑 앱을 선택하세요.",

View File

@@ -183,8 +183,15 @@ export const msDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "Log masuk TokenPocket",
walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.",
walletTpLoginBtn: "Log masuk dengan TokenPocket",
walletTpWaiting: "Menunggu tandatangan anda dalam TokenPocket…",
walletTpReopen: "Buka semula TokenPocket",
favoritesFilters: "Penapis",
favoriteSessionExpired: "Sesi anda telah tamat. Sila log masuk semula.",
loadFailed: "Gagal memuatkan kegemaran anda.",
walletChooseDesktop:
"Pilih dompet yang ingin anda gunakan. Pada desktop, pasang sambungan pelayar yang sepadan.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka laman ini.",

View File

@@ -181,8 +181,15 @@ export const viDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "Đăng nhập TokenPocket",
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í.",
walletTpLoginBtn: "Đăng nhập bằng TokenPocket",
walletTpWaiting: "Đang chờ bạn ký trong TokenPocket…",
walletTpReopen: "Mở lại TokenPocket",
favoritesFilters: "Bộ lọc",
favoriteSessionExpired: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
loadFailed: "Không thể tải mục yêu thích của bạn.",
walletChooseDesktop:
"Chọn ví bạn muốn dùng. Trên máy tính, hãy cài tiện ích mở rộng trình duyệt tương ứng.",
walletChooseMobile: "Chọn ứng dụng ví để mở trang này.",

View File

@@ -174,8 +174,15 @@ export const zhDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletTokenPocketLogin: "TokenPocket 登录",
walletTpMobileDesc:
"在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。",
walletTpLoginBtn: "使用 TokenPocket 登录",
walletTpWaiting: "等待你在 TokenPocket 中完成签名…",
walletTpReopen: "重新打开 TokenPocket",
favoritesFilters: "筛选",
favoriteSessionExpired: "登录已过期,请重新登录。",
loadFailed: "无法加载收藏,请稍后重试。",
walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。",
walletChooseMobile: "选择钱包 App 打开本站。",
walletDesktopHint:

View File

@@ -1,4 +1,5 @@
import { QRCodeSVG } from "qrcode.react";
import { LoaderCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import {
@@ -132,6 +133,10 @@ export function WalletLoginModal() {
const signInjected = async () => {
setError("");
if (!getInjectedEthereum()) {
setError(t("walletNoBrowserWalletDesc"));
return;
}
setState("signing");
await signInInjected()
.catch((err) => {
@@ -146,13 +151,19 @@ export function WalletLoginModal() {
openWalletDeepLink(kind);
};
const startTokenPocketQr = async () => {
// TokenPocket login. The backend returns a `tpoutside://pull.activity` deep
// link (a one-off SIGN request, not a dApp-browser link). On mobile we open
// 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"));
@@ -189,68 +200,81 @@ export function WalletLoginModal() {
</div>
<div className="mt-5 grid gap-3">
{!mobileDevice || hasInjected ? (
<>
{/* Injected wallet: browser extension (desktop) or in-wallet browser. */}
{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>
) : !mobileDevice ? (
<button
type="button"
onClick={() => void signInjected()}
className="flex items-center justify-center gap-2 rounded-2xl border border-ark-gold/50 bg-ark-gold/5 px-4 py-3 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10"
>
{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
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"
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"
>
{state === "signing"
? t("walletSigning")
<WalletBrandIcon kind="tokenPocket" size={20} />
{state === "tpLoading"
? t("loading")
: mobileDevice
? t("walletUseCurrent")
: t("walletInjected")}
? t("walletTpLoginBtn")
: t("walletGenerateQr")}
</button>
{!mobileDevice ? (
<p className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
{t("walletDesktopHint")}
) : 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>
) : null}
</>
) : (
<div className="grid gap-2">
<p className="text-sm font-medium text-neutral-300">
{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-4 text-left text-base font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
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"
>
<WalletBrandIcon kind={option.kind} />
<span>{t(option.labelKey)}</span>
{t("walletTpReopen")}
</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}
</div>
)}
</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"
@@ -263,36 +287,56 @@ export function WalletLoginModal() {
{showOther ? (
<div className="grid gap-4 px-4 pb-4">
{/* TokenPocket QR — stable path for China users (works on desktop too). */}
<div className="grid gap-2">
<p className="text-sm font-semibold text-neutral-200">
{t("walletTokenPocketQr")}
</p>
<p className="text-xs leading-5 text-neutral-400">
{t("walletTokenPocketQrDesc")}
</p>
<button
type="button"
onClick={() => void startTokenPocketQr()}
disabled={state === "tpLoading"}
className="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"
>
{state === "tpLoading"
? t("loading")
: t("walletGenerateQr")}
</button>
{tpRequest ? (
<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>
) : null}
</div>
{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}
</div>
) : null}
{/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */}
<div className="grid gap-2 border-t border-white/10 pt-4">
{/* MetaMask / imToken QR via WalletConnect — needs a real id. */}
<div
className={
mobileDevice && !hasInjected
? "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>