From 7abe4a868c27170a2bc7179755aa8077c8932ad2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 03:43:13 +0800 Subject: [PATCH] feat: redesign wallet login and favorites, fix desktop/mobile bugs - Remove forced BNB chain switch on injected login (signature is chain-agnostic) - Refine isMobileDevice so touch Macs stay on desktop flow - Wire RainbowKit/WalletConnect as a real MetaMask/imToken QR fallback, gated on a valid VITE_WALLETCONNECT_PROJECT_ID - Rebuild login modal: single desktop primary action, collapsible other methods, mobile open-app fallback feedback, brand icons - Add My Favorites entry points (header, mobile menu, wallet dropdown) - Favorites page: error retry, mobile filter drawer - Auto sign-out and re-login prompt on favorites 401 - Full native translations for all wallet strings across 7 locales Co-Authored-By: Claude Opus 4.8 (1M context) --- src/favorites/FavoritesProvider.tsx | 24 +- src/favorites/api.ts | 17 +- src/layouts/PublicLayout.tsx | 30 ++- src/locales/en.ts | 16 +- src/locales/id.ts | 35 ++- src/locales/ja.ts | 31 +++ src/locales/ko.ts | 33 ++- src/locales/ms.ts | 35 ++- src/locales/vi.ts | 34 ++- src/locales/zh-CN.ts | 16 +- src/pages/Favorites/index.tsx | 104 +++++---- src/wallet/WalletBrandIcon.tsx | 33 +++ src/wallet/WalletButton.tsx | 12 + src/wallet/WalletLoginModal.tsx | 325 ++++++++++++++++++++++------ src/wallet/deepLinks.ts | 10 + src/wallet/injected.ts | 31 +-- src/wallet/useWalletConnectLogin.ts | 84 +++++++ 17 files changed, 715 insertions(+), 155 deletions(-) create mode 100644 src/wallet/WalletBrandIcon.tsx create mode 100644 src/wallet/useWalletConnectLogin.ts diff --git a/src/favorites/FavoritesProvider.tsx b/src/favorites/FavoritesProvider.tsx index 3c4c082..f497938 100644 --- a/src/favorites/FavoritesProvider.tsx +++ b/src/favorites/FavoritesProvider.tsx @@ -11,7 +11,12 @@ import { import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; import { useWallet } from "../wallet/WalletProvider"; -import { addFavorite, getFavoriteIds, removeFavorite } from "./api"; +import { + addFavorite, + getFavoriteIds, + isFavoritesAuthError, + removeFavorite, +} from "./api"; type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; @@ -30,7 +35,13 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { const { t } = useI18n(); const { showToast } = useToast(); const wallet = useWallet(); - const { address, openLoginModal, status, token } = wallet; + const { address, logout, openLoginModal, status, token } = wallet; + + const handleAuthError = useCallback(() => { + logout(); + openLoginModal(); + showToast(t("favoriteSessionExpired")); + }, [logout, openLoginModal, showToast, t]); const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); const [knownIds, setKnownIds] = useState>(() => new Set()); const [pendingIds, setPendingIds] = useState>(() => new Set()); @@ -113,6 +124,8 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { ids.forEach((id) => next.add(id)); return next; }); + } catch (error) { + if (isFavoritesAuthError(error)) handleAuthError(); } finally { requestIds.forEach((id) => inFlightIdsRef.current.delete(id)); if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) { @@ -122,7 +135,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { }, 0); } } - }, []); + }, [handleAuthError]); const ensureFavoriteIds = useCallback( async (resourceIds: string[]) => { @@ -158,7 +171,8 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { ); } catch (error) { markFavorite(resourceId, currentlyFavorite); - showToast(t("favoriteFailed"), "error"); + if (isFavoritesAuthError(error)) handleAuthError(); + else showToast(t("favoriteFailed"), "error"); throw error; } finally { setPendingIds((prev) => { @@ -168,7 +182,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { }); } }, - [favoriteIds, markFavorite, showToast, t, token], + [favoriteIds, handleAuthError, markFavorite, showToast, t, token], ); const toggleFavorite = useCallback( diff --git a/src/favorites/api.ts b/src/favorites/api.ts index 0d5dd37..a25f47d 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -30,8 +30,23 @@ function authHeaders(token: string): HeadersInit { return { Authorization: `Bearer ${token}` }; } +/** HTTP error that preserves the status code so callers can react to 401s. */ +export class FavoriteHttpError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message || `Request failed (${status})`); + this.name = "FavoriteHttpError"; + this.status = status; + } +} + +/** True when an error means the wallet session is no longer authorized. */ +export function isFavoritesAuthError(error: unknown): boolean { + return error instanceof FavoriteHttpError && error.status === 401; +} + async function parseJSON(res: Response): Promise { - if (!res.ok) throw new Error(await res.text()); + if (!res.ok) throw new FavoriteHttpError(res.status, await res.text()); return res.json() as Promise; } diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index febd2fd..0b1e969 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; +import { + ChevronDown, + Heart, + Menu, + Search as SearchIcon, + X, +} from "lucide-react"; import { AnimatePresence, m } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; @@ -637,6 +643,19 @@ export function PublicLayout() {
+ + +
+ +
+ + - - - +
) : items.length === 0 ? (
diff --git a/src/wallet/WalletBrandIcon.tsx b/src/wallet/WalletBrandIcon.tsx new file mode 100644 index 0000000..86eeaed --- /dev/null +++ b/src/wallet/WalletBrandIcon.tsx @@ -0,0 +1,33 @@ +import type { WalletKind } from "./injected"; + +type Brand = { bg: string; label: string }; + +const brands: Record = { + tokenPocket: { bg: "#2980FE", label: "TP" }, + metaMask: { bg: "#F6851B", label: "M" }, + imToken: { bg: "#11C4D1", label: "im" }, +}; + +/** + * Lightweight brand badge for wallet buttons — a rounded square tinted with the + * wallet's brand color and its monogram. Keeps bundle small while making each + * wallet visually distinguishable. + */ +export function WalletBrandIcon({ + kind, + size = 28, +}: { + kind: WalletKind; + size?: number; +}) { + const brand = brands[kind]; + return ( + + ); +} diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index d75f94f..33deaf7 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -1,5 +1,8 @@ +import { Heart } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; import { useI18n } from "../i18n"; +import { useLocalizedPath } from "../useLocalizedPath"; import { shortenAddress, useWallet } from "./WalletProvider"; export function WalletButton({ @@ -10,6 +13,7 @@ export function WalletButton({ onOpenLogin?: () => void; }) { const { t } = useI18n(); + const lp = useLocalizedPath(); const wallet = useWallet(); const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -53,6 +57,14 @@ export function WalletButton({
{wallet.address}
+ setOpen(false)} + className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-neutral-100 transition hover:bg-ark-gold/10 hover:text-ark-gold" + > + + {t("favorites")} + + {!mobileDevice ? ( +

+ {t("walletDesktopHint")} +

+ ) : null} + + ) : ( +
+

+ {t("walletOpenWalletApp")} +

+ {appWallets.map((option) => ( - ); - })} + ))} + {openingWallet ? ( +
+

{withWallet("walletOpening", openingWallet)}

+

{t("walletAppNotInstalled")}

+
+ + {withWallet("walletDownloadApp", openingWallet)} + + +
+
+ ) : null} +
+ )} + +
+ + + {showOther ? ( +
+ {/* TokenPocket QR — stable path for China users (works on desktop too). */} +
+

+ {t("walletTokenPocketQr")} +

+

+ {t("walletTokenPocketQrDesc")} +

+ + {tpRequest ? ( +
+ +

+ {t("walletQrUseAnotherDevice")} +

+
+ ) : null} +
+ + {/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */} +
+

+ {t("walletRainbowFallback")} +

+

+ {t("walletRainbowFallbackDesc")} +

+ {wc.available ? ( + <> + +

+ {t("walletNetworkWarning")} +

+ + ) : ( +

+ {t("walletRainbowUnavailable")} +

+ )} +
+
+ ) : null}
- {!mobileDevice ? ( -

- {t("walletDesktopHint")} -

- ) : null}
- {error ? ( + {error || wc.error ? (

- {error} + {error || wc.error}

) : null}
); } + +function walletNameKey(kind: WalletKind): string { + if (kind === "tokenPocket") return "walletTokenPocket"; + if (kind === "metaMask") return "walletMetaMask"; + return "walletImToken"; +} diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts index 33da445..08bdc35 100644 --- a/src/wallet/deepLinks.ts +++ b/src/wallet/deepLinks.ts @@ -29,3 +29,13 @@ export function openWalletDeepLink(kind: WalletKind): void { if (typeof window === "undefined") return; window.location.href = walletDeepLink(kind); } + +const downloadUrls: Record = { + tokenPocket: "https://www.tokenpocket.pro/en/download/app", + metaMask: "https://metamask.io/download/", + imToken: "https://token.im/download", +}; + +export function walletDownloadUrl(kind: WalletKind): string { + return downloadUrls[kind]; +} diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index 16fab04..ef40c8b 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -2,8 +2,6 @@ import { requestWalletNonce, verifyWalletSignature } from "./api"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; -const bnbChainIdHex = "0x38"; - export type EthereumProvider = { isMetaMask?: boolean; isTokenPocket?: boolean; @@ -36,30 +34,6 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { return match ?? null; } -async function ensureBnbChain(ethereum: EthereumProvider): Promise { - try { - await ethereum.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: bnbChainIdHex }], - }); - } catch (error) { - const code = (error as { code?: number | string }).code; - if (code !== 4902 && code !== "4902") throw error; - await ethereum.request({ - method: "wallet_addEthereumChain", - params: [ - { - blockExplorerUrls: ["https://bscscan.com"], - chainId: bnbChainIdHex, - chainName: "BNB Smart Chain", - nativeCurrency: { decimals: 18, name: "BNB", symbol: "BNB" }, - rpcUrls: ["https://bsc-dataseed.binance.org"], - }, - ], - }); - } -} - export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; @@ -67,8 +41,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ const ethereum = getInjectedWallet(kind); if (!ethereum) throw new Error("No injected wallet found"); - await ensureBnbChain(ethereum); - + // Login is signature-only (EIP-191 personal_sign). The backend verifies the + // recovered address and never inspects chainId, so we deliberately do NOT + // switch or add any chain — that only adds a failure-prone wallet popup. const accounts = await ethereum.request({ method: "eth_requestAccounts", }); diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts new file mode 100644 index 0000000..4240053 --- /dev/null +++ b/src/wallet/useWalletConnectLogin.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useAccount, useDisconnect, useSignMessage } from "wagmi"; +import { requestWalletNonce, verifyWalletSignature } from "./api"; +import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; +import { useWallet } from "./WalletProvider"; + +export type WalletConnectLoginState = "idle" | "connecting" | "signing"; + +/** + * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. + * + * Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account + * is connected, request a nonce, sign it with `personal_sign` through wagmi, + * verify against the backend and complete our own JWT login. The wagmi/WC + * session is only needed for the signature, so we disconnect right after. + * + * Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is + * missing `available` is false and `start` is a no-op, so callers can hide or + * disable the entry instead of triggering a connect with a fake project id. + */ +export function useWalletConnectLogin() { + const available = hasWalletConnectProjectId(); + const { completeLogin } = useWallet(); + const { address, isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { disconnect } = useDisconnect(); + const { openConnectModal } = useConnectModal(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + const pendingRef = useRef(false); + + const reset = useCallback(() => { + pendingRef.current = false; + setState("idle"); + setError(""); + }, []); + + const start = useCallback(() => { + if (!available) return; + setError(""); + pendingRef.current = true; + setState("connecting"); + // When already connected, openConnectModal is undefined; the effect below + // picks up the existing account and proceeds straight to signing. + openConnectModal?.(); + }, [available, openConnectModal]); + + useEffect(() => { + if (!pendingRef.current || !isConnected || !address) return; + pendingRef.current = false; + setState("signing"); + let cancelled = false; + void (async () => { + try { + const nonce = await requestWalletNonce(address); + const signature = await signMessageAsync({ message: nonce.message }); + const verified = await verifyWalletSignature({ + address, + message: nonce.message, + signature, + }); + if (cancelled) return; + completeLogin(verified.token, verified.wallet); + setState("idle"); + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "WalletConnect login failed", + ); + setState("idle"); + } + } finally { + // We only needed a one-off signature, not a persistent wagmi session. + disconnect(); + } + })(); + return () => { + cancelled = true; + }; + }, [address, completeLogin, disconnect, isConnected, signMessageAsync]); + + return { available, state, error, start, reset }; +}