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

@@ -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>