terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
17 changed files with 715 additions and 155 deletions
Showing only changes of commit 7abe4a868c - Show all commits

View File

@@ -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<Set<string>>(() => new Set());
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
const [pendingIds, setPendingIds] = useState<Set<string>>(() => 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(

View File

@@ -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<T>(res: Response): Promise<T> {
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<T>;
}

View File

@@ -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() {
</nav>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none">
<Link
to={lp("/favorites")}
aria-label={t("favorites")}
title={t("favorites")}
aria-current={na("favorites") ? "page" : undefined}
className={`hidden h-10 w-10 shrink-0 items-center justify-center rounded-full border bg-[#1a1b20] outline-none transition hover:border-ark-gold/50 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${
na("favorites")
? "border-ark-gold/70 text-ark-gold"
: "border-ark-line text-neutral-200"
}`}
>
<Heart size={18} strokeWidth={2} />
</Link>
<div ref={desktopSearchRef} className="hidden md:block">
<button
type="button"
@@ -722,6 +741,15 @@ export function PublicLayout() {
>
{t("popular")}
</Link>
<Link
to={lp("/favorites")}
className={`${mobileMenuNavClassName(na("favorites"))} flex items-center gap-2`}
aria-current={na("favorites") ? "page" : undefined}
onClick={() => setOpen(false)}
>
<Heart size={16} strokeWidth={2} />
{t("favorites")}
</Link>
<div className="mt-2 w-full max-w-xs">
<WalletButton compact onOpenLogin={() => setOpen(false)} />
</div>

View File

@@ -171,7 +171,7 @@ export const enDict: Dict = {
walletDisconnect: "Disconnect",
walletLoginTitle: "Connect wallet",
walletLoginDesc:
"Sign a message to verify your BNB Chain wallet address. No transaction or gas fee.",
"Sign a message to verify your wallet address. No transaction or gas fee.",
walletInjected: "Use browser wallet",
walletInjectedDesc: "Sign with the wallet available in this browser.",
walletNoBrowserWallet: "No browser wallet detected",
@@ -183,14 +183,19 @@ export const enDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "Filters",
favoriteSessionExpired: "Your session expired. Please sign in again.",
walletChooseDesktop:
"Choose the wallet you want to use. On desktop, install the matching browser extension and switch to BNB Chain.",
"Choose the wallet you want to use. On desktop, install the matching browser extension.",
walletChooseMobile: "Choose a wallet app to open this site.",
walletDesktopHint:
"If no wallet opens after clicking, make sure the matching browser extension is installed and enabled.",
walletInstallSelected:
"No {wallet} browser extension detected. Install or enable it, then try again.",
walletOpen: "Open",
walletQrLogin: "QR login",
walletMobileQrDesc:
"Use another device to scan this QR code and log in to this browser.",
walletTokenPocketQr: "TokenPocket QR login",
walletTokenPocketQrDesc:
"Recommended for China users. Scan with TokenPocket and sign to return login to this browser.",
@@ -212,6 +217,13 @@ export const enDict: Dict = {
walletLoginSuccess: "Wallet connected",
walletLoginFailed: "Wallet login failed",
walletDisconnected: "Wallet disconnected",
walletOtherMethods: "Other login methods",
walletUseCurrent: "Use current wallet",
walletOpening: "Opening {wallet}…",
walletAppNotInstalled: "If nothing opened, the app may not be installed.",
walletDownloadApp: "Download {wallet}",
walletRetry: "Try again",
walletConnecting: "Connecting…",
featureUnavailable: "Not available yet",
featureUnavailableDesc: "This feature is not available yet.",
confirm: "Got it",

View File

@@ -171,8 +171,31 @@ export const idDict: Dict = {
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.",
walletInjected: "Gunakan dompet browser",
walletInjectedDesc:
"Tanda tangani dengan dompet yang tersedia di browser ini.",
walletNoBrowserWallet: "Tidak ada dompet browser terdeteksi",
walletNoBrowserWalletDesc:
"Pasang atau aktifkan ekstensi dompet browser, seperti MetaMask.",
walletOpenWalletApp: "Buka aplikasi dompet",
walletOpenWalletAppDesc:
"Buka situs ini di aplikasi dompet Anda, lalu tanda tangani untuk masuk.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "Filter",
favoriteSessionExpired: "Sesi Anda telah berakhir. Silakan masuk lagi.",
walletChooseDesktop:
"Pilih dompet yang ingin digunakan. Di desktop, pasang ekstensi browser yang sesuai.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka situs ini.",
walletDesktopHint:
"Jika tidak ada dompet terbuka setelah diklik, pastikan ekstensi browser yang sesuai sudah terpasang dan diaktifkan.",
walletInstallSelected:
"Ekstensi browser {wallet} tidak terdeteksi. Pasang atau aktifkan, lalu coba lagi.",
walletOpen: "Buka",
walletQrLogin: "Login QR",
walletMobileQrDesc:
"Gunakan perangkat lain untuk memindai kode QR ini dan masuk di browser ini.",
walletTokenPocketQr: "Login QR TokenPocket",
walletTokenPocketQrDesc:
"Direkomendasikan untuk pengguna Tiongkok. Pindai dengan TokenPocket dan tanda tangani untuk login di browser ini.",
@@ -194,6 +217,14 @@ export const idDict: Dict = {
walletLoginSuccess: "Dompet terhubung",
walletLoginFailed: "Login dompet gagal",
walletDisconnected: "Dompet terputus",
walletOtherMethods: "Metode login lainnya",
walletUseCurrent: "Gunakan dompet saat ini",
walletOpening: "Membuka {wallet}…",
walletAppNotInstalled:
"Jika tidak ada yang terbuka, aplikasi mungkin belum terpasang.",
walletDownloadApp: "Unduh {wallet}",
walletRetry: "Coba lagi",
walletConnecting: "Menghubungkan…",
featureUnavailable: "Belum tersedia",
featureUnavailableDesc: "Fitur ini belum tersedia.",
confirm: "Mengerti",

View File

@@ -194,6 +194,37 @@ export const jaDict: Dict = {
walletLoginSuccess: "ウォレットを接続しました",
walletLoginFailed: "ウォレットログインに失敗しました",
walletDisconnected: "ウォレットを切断しました",
walletNoBrowserWallet: "ブラウザウォレットが見つかりません",
walletNoBrowserWalletDesc:
"MetaMask などのブラウザウォレット拡張機能をインストールまたは有効にしてください。",
walletOpenWalletApp: "ウォレットアプリで開く",
walletOpenWalletAppDesc:
"このサイトをウォレットアプリで開き、署名してログインしてください。",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "フィルター",
favoriteSessionExpired:
"セッションの有効期限が切れました。もう一度ログインしてください。",
walletChooseDesktop:
"使用するウォレットを選択してください。デスクトップの場合は対応するブラウザ拡張機能をインストールしてください。",
walletChooseMobile: "このサイトを開くウォレットアプリを選択してください。",
walletDesktopHint:
"クリックしてもウォレットが開かない場合は、対応するブラウザ拡張機能がインストールされ有効になっているか確認してください。",
walletInstallSelected:
"{wallet} のブラウザ拡張機能が検出されません。インストールまたは有効にしてから再試行してください。",
walletOpen: "開く",
walletQrLogin: "QR ログイン",
walletMobileQrDesc:
"別のデバイスでこの QR コードをスキャンして、このブラウザにログインしてください。",
walletOtherMethods: "他のログイン方法",
walletUseCurrent: "現在のウォレットを使用",
walletOpening: "{wallet} を起動中…",
walletAppNotInstalled:
"何も起動しない場合は、アプリがインストールされていない可能性があります。",
walletDownloadApp: "{wallet} をダウンロード",
walletRetry: "再試行",
walletConnecting: "接続中…",
featureUnavailable: "未公開",
featureUnavailableDesc: "この機能はまだご利用いただけません。",
confirm: "了解",

View File

@@ -170,8 +170,30 @@ export const koDict: Dict = {
walletLoginTitle: "지갑 연결",
walletLoginDesc:
"메시지 서명으로 지갑 주소를 확인합니다. 트랜잭션이나 가스 수수료는 없습니다.",
walletInjected: "브라우저 지갑 / DApp 브라우저",
walletInjectedDesc: "현재 브라우저에서 사용 가능한 지갑을 사용합니다.",
walletInjected: "브라우저 지갑 사용",
walletInjectedDesc: " 브라우저에서 사용 가능한 지갑으로 서명합니다.",
walletNoBrowserWallet: "브라우저 지갑을 찾을 수 없습니다",
walletNoBrowserWalletDesc:
"MetaMask 등의 브라우저 지갑 확장 프로그램을 설치하거나 활성화하세요.",
walletOpenWalletApp: "지갑 앱으로 열기",
walletOpenWalletAppDesc:
"지갑 앱에서 이 사이트를 열고 서명하여 로그인하세요.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "필터",
favoriteSessionExpired: "세션이 만료되었습니다. 다시 로그인해 주세요.",
walletChooseDesktop:
"사용할 지갑을 선택하세요. 데스크톱에서는 해당 브라우저 확장 프로그램을 설치하세요.",
walletChooseMobile: "이 사이트를 열 지갑 앱을 선택하세요.",
walletDesktopHint:
"클릭 후 지갑이 열리지 않으면 해당 브라우저 확장 프로그램이 설치되고 활성화되어 있는지 확인하세요.",
walletInstallSelected:
"{wallet} 브라우저 확장 프로그램을 찾을 수 없습니다. 설치하거나 활성화한 후 다시 시도하세요.",
walletOpen: "열기",
walletQrLogin: "QR 로그인",
walletMobileQrDesc:
"다른 기기로 이 QR 코드를 스캔하여 이 브라우저에 로그인하세요.",
walletTokenPocketQr: "TokenPocket QR 로그인",
walletTokenPocketQrDesc:
"중국 사용자에게 권장됩니다. TokenPocket으로 스캔하고 서명하면 이 브라우저에서 로그인이 완료됩니다.",
@@ -193,6 +215,13 @@ export const koDict: Dict = {
walletLoginSuccess: "지갑이 연결되었습니다",
walletLoginFailed: "지갑 로그인에 실패했습니다",
walletDisconnected: "지갑 연결이 해제되었습니다",
walletOtherMethods: "다른 로그인 방법",
walletUseCurrent: "현재 지갑 사용",
walletOpening: "{wallet} 열는 중…",
walletAppNotInstalled: "열리지 않으면 앱이 설치되어 있지 않을 수 있습니다.",
walletDownloadApp: "{wallet} 다운로드",
walletRetry: "다시 시도",
walletConnecting: "연결 중…",
featureUnavailable: "준비 중",
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
confirm: "확인",

View File

@@ -171,8 +171,31 @@ export const msDict: Dict = {
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.",
walletInjected: "Guna dompet pelayar",
walletInjectedDesc:
"Tandatangani dengan dompet yang tersedia dalam pelayar ini.",
walletNoBrowserWallet: "Tiada dompet pelayar dikesan",
walletNoBrowserWalletDesc:
"Pasang atau aktifkan sambungan dompet pelayar, seperti MetaMask.",
walletOpenWalletApp: "Buka aplikasi dompet",
walletOpenWalletAppDesc:
"Buka laman ini dalam aplikasi dompet anda, kemudian tandatangani untuk log masuk.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "Penapis",
favoriteSessionExpired: "Sesi anda telah tamat. Sila log masuk semula.",
walletChooseDesktop:
"Pilih dompet yang ingin anda gunakan. Pada desktop, pasang sambungan pelayar yang sepadan.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka laman ini.",
walletDesktopHint:
"Jika tiada dompet terbuka selepas klik, pastikan sambungan pelayar yang sepadan telah dipasang dan diaktifkan.",
walletInstallSelected:
"Tiada sambungan pelayar {wallet} dikesan. Pasang atau aktifkannya, kemudian cuba lagi.",
walletOpen: "Buka",
walletQrLogin: "Log masuk QR",
walletMobileQrDesc:
"Guna peranti lain untuk mengimbas kod QR ini dan log masuk pada pelayar ini.",
walletTokenPocketQr: "Log masuk QR TokenPocket",
walletTokenPocketQrDesc:
"Disyorkan untuk pengguna China. Imbas dengan TokenPocket dan tandatangani untuk log masuk pada pelayar ini.",
@@ -194,6 +217,14 @@ export const msDict: Dict = {
walletLoginSuccess: "Dompet disambungkan",
walletLoginFailed: "Log masuk dompet gagal",
walletDisconnected: "Dompet diputuskan",
walletOtherMethods: "Kaedah log masuk lain",
walletUseCurrent: "Guna dompet semasa",
walletOpening: "Membuka {wallet}…",
walletAppNotInstalled:
"Jika tiada yang terbuka, aplikasi mungkin belum dipasang.",
walletDownloadApp: "Muat turun {wallet}",
walletRetry: "Cuba lagi",
walletConnecting: "Menyambung…",
featureUnavailable: "Belum tersedia",
featureUnavailableDesc: "Ciri ini belum tersedia.",
confirm: "Faham",

View File

@@ -170,8 +170,30 @@ export const viDict: Dict = {
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: "ng ví đã có trong trình duyệt hiện tại.",
walletInjected: "Dùng ví trình duyệt",
walletInjectedDesc: "Ký bằng ví đã có trong trình duyệt này.",
walletNoBrowserWallet: "Không tìm thấy ví trình duyệt",
walletNoBrowserWalletDesc:
"Cài đặt hoặc bật tiện ích mở rộng ví trình duyệt, chẳng hạn như MetaMask.",
walletOpenWalletApp: "Mở ứng dụng ví",
walletOpenWalletAppDesc:
"Mở trang này trong ứng dụng ví của bạn, sau đó ký để đăng nhập.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
favoritesFilters: "Bộ lọc",
favoriteSessionExpired: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
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.",
walletDesktopHint:
"Nếu không có ví nào mở sau khi nhấn, hãy đảm bảo tiện ích mở rộng tương ứng đã được cài đặt và bật.",
walletInstallSelected:
"Không tìm thấy tiện ích mở rộng {wallet}. Hãy cài đặt hoặc bật nó, rồi thử lại.",
walletOpen: "Mở",
walletQrLogin: "Đăng nhập QR",
walletMobileQrDesc:
"Dùng thiết bị khác quét mã QR này để đăng nhập trên trình duyệt này.",
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.",
@@ -193,6 +215,14 @@ export const viDict: Dict = {
walletLoginSuccess: "Đã kết nối ví",
walletLoginFailed: "Đăng nhập ví thất bại",
walletDisconnected: "Đã ngắt kết nối ví",
walletOtherMethods: "Phương thức đăng nhập khác",
walletUseCurrent: "Dùng ví hiện tại",
walletOpening: "Đang mở {wallet}…",
walletAppNotInstalled:
"Nếu không có gì mở, ứng dụng có thể chưa được cài đặt.",
walletDownloadApp: "Tải {wallet}",
walletRetry: "Thử lại",
walletConnecting: "Đang kết nối…",
featureUnavailable: "Chưa khả dụng",
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
confirm: "Đã hiểu",

View File

@@ -164,7 +164,7 @@ export const zhDict: Dict = {
walletConnectedAs: "已连接钱包",
walletDisconnect: "断开连接",
walletLoginTitle: "连接钱包",
walletLoginDesc: "签名验证 BNB Chain 钱包地址,不会发起交易,也不需要 Gas。",
walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。",
walletInjected: "使用浏览器钱包登录",
walletInjectedDesc: "签名验证当前浏览器里的钱包。",
walletNoBrowserWallet: "未检测到浏览器钱包",
@@ -174,13 +174,16 @@ export const zhDict: Dict = {
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletChooseDesktop:
"选择你要使用的钱包。电脑端需要先安装对应浏览器插件,并切换到 BNB Chain。",
favoritesFilters: "筛选",
favoriteSessionExpired: "登录已过期,请重新登录。",
walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。",
walletChooseMobile: "选择钱包 App 打开本站。",
walletDesktopHint:
"如果点击后没有弹出钱包,请确认已安装并启用对应的钱包浏览器插件。",
walletInstallSelected: "未检测到 {wallet} 浏览器插件,请先安装或启用后再试。",
walletOpen: "打开",
walletQrLogin: "扫码登录",
walletMobileQrDesc: "适合用另一台设备扫描二维码登录当前浏览器。",
walletTokenPocketQr: "TokenPocket 扫码登录",
walletTokenPocketQrDesc:
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",
@@ -202,6 +205,13 @@ export const zhDict: Dict = {
walletLoginSuccess: "钱包已连接",
walletLoginFailed: "钱包登录失败",
walletDisconnected: "钱包已断开",
walletOtherMethods: "其他登录方式",
walletUseCurrent: "使用当前钱包登录",
walletOpening: "正在打开 {wallet}…",
walletAppNotInstalled: "如果没有跳转,可能是未安装该 App。",
walletDownloadApp: "下载 {wallet}",
walletRetry: "重试",
walletConnecting: "连接中…",
featureUnavailable: "未开放",
featureUnavailableDesc: "该功能暂未开放。",
confirm: "知道了",

View File

@@ -1,4 +1,4 @@
import { Heart, Search, SlidersHorizontal, X } from "lucide-react";
import { Heart, RotateCcw, Search, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
@@ -10,7 +10,11 @@ import {
type Resource,
} from "../../api";
import { FavoriteButton } from "../../favorites/FavoriteButton";
import { listFavorites, type FavoriteSort } from "../../favorites/api";
import {
isFavoritesAuthError,
listFavorites,
type FavoriteSort,
} from "../../favorites/api";
import { useFavorites } from "../../favorites/FavoritesProvider";
import { langQuery, useI18n, type Lang } from "../../i18n";
import { homePathForLang } from "../../languageRoutes";
@@ -134,6 +138,8 @@ export default function Favorites() {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const [showFilters, setShowFilters] = useState(false);
useSetPageTitle(t("favorites"));
@@ -167,7 +173,12 @@ export default function Favorites() {
resources.forEach((resource) => markFavorite(resource.id, true));
})
.catch((err) => {
if (!cancelled)
if (cancelled) return;
if (isFavoritesAuthError(err)) {
wallet.logout();
wallet.openLoginModal();
return;
}
setError(err instanceof Error ? err.message : t("loadFailed"));
})
.finally(() => {
@@ -176,17 +187,7 @@ export default function Favorites() {
return () => {
cancelled = true;
};
}, [
category,
lang,
markFavorite,
page,
query,
sort,
t,
wallet.status,
wallet.token,
]);
}, [category, lang, markFavorite, page, query, reloadKey, sort, t, wallet]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const hasFilters = Boolean(category || query || sort !== "favorited_at");
@@ -260,11 +261,27 @@ export default function Favorites() {
/>
</label>
{/* Mobile-only toggle: collapse sort/category into a "Filters" drawer. */}
<button
type="button"
onClick={() => setShowFilters((value) => !value)}
aria-expanded={showFilters}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/10 bg-[#101016] px-4 text-sm font-medium text-neutral-200 transition hover:border-ark-gold/40 hover:text-ark-gold md:hidden"
>
<SlidersHorizontal className="h-4 w-4" />
{t("favoritesFilters")}
</button>
<div
className={`${showFilters ? "grid" : "hidden"} gap-3 md:contents`}
>
<label className="relative block">
<SlidersHorizontal className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<select
value={sort}
onChange={(event) => setSort(event.target.value as FavoriteSort)}
onChange={(event) =>
setSort(event.target.value as FavoriteSort)
}
className="h-11 w-full appearance-none rounded-full border border-white/10 bg-[#101016] pl-10 pr-4 text-sm text-white outline-none focus:border-ark-gold/60"
>
{sortOptions.map((option) => (
@@ -287,6 +304,7 @@ export default function Favorites() {
</option>
))}
</select>
</div>
<button
type="submit"
@@ -320,8 +338,16 @@ export default function Favorites() {
))}
</div>
) : error ? (
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="flex flex-col items-start gap-3 rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
<p>{error}</p>
<button
type="button"
onClick={() => setReloadKey((value) => value + 1)}
className="inline-flex items-center gap-1.5 rounded-full border border-red-400/40 px-3 py-1.5 text-xs font-semibold text-red-100 transition hover:bg-red-500/20"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("walletRetry")}
</button>
</div>
) : items.length === 0 ? (
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-3xl border border-white/10 bg-[#17171d] p-8 text-center">

View File

@@ -0,0 +1,33 @@
import type { WalletKind } from "./injected";
type Brand = { bg: string; label: string };
const brands: Record<WalletKind, Brand> = {
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 (
<span
aria-hidden="true"
style={{ width: size, height: size, backgroundColor: brand.bg }}
className="inline-flex shrink-0 items-center justify-center rounded-lg text-[11px] font-bold leading-none text-white"
>
{brand.label}
</span>
);
}

View File

@@ -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<HTMLDivElement>(null);
@@ -53,6 +57,14 @@ export function WalletButton({
<div className="truncate px-3 py-2 text-xs text-neutral-400">
{wallet.address}
</div>
<Link
to={lp("/favorites")}
onClick={() => 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"
>
<Heart size={16} strokeWidth={2} />
{t("favorites")}
</Link>
<button
type="button"
onClick={() => {

View File

@@ -1,86 +1,164 @@
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import { openWalletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import {
createTokenPocketLoginRequest,
fetchTokenPocketLoginResult,
verifyWalletSignature,
type TokenPocketLoginRequest,
} from "./api";
import { openWalletDeepLink, walletDownloadUrl } from "./deepLinks";
import { getInjectedEthereum, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon";
type ModalState = "idle" | "signing";
const pollIntervalMs = 1800;
type WalletOption = {
kind: WalletKind;
labelKey: string;
};
type ModalState = "idle" | "signing" | "tpLoading" | "tpPolling";
const walletOptions: WalletOption[] = [
{ kind: "tokenPocket", labelKey: "walletTokenPocket" },
{ kind: "metaMask", labelKey: "walletMetaMask" },
{ kind: "imToken", labelKey: "walletImToken" },
const appWallets: { kind: WalletKind; labelKey: string }[] = [
{ kind: "tokenPocket", labelKey: "walletOpenTokenPocket" },
{ kind: "metaMask", labelKey: "walletOpenMetaMask" },
{ kind: "imToken", labelKey: "walletOpenImToken" },
];
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
return (
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
ua,
) ||
(/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1)
);
)
) {
return true;
}
// iPadOS 13+ reports a desktop "Macintosh" UA. A genuine touch-primary iPad
// exposes a coarse pointer; a Mac (even with a touch peripheral) keeps a fine
// pointer, so it stays on the desktop flow instead of the wallet-app jump.
const coarsePointer =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(pointer: coarse)").matches;
return /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1 && coarsePointer;
}
export function WalletLoginModal() {
const { t } = useI18n();
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
useWallet();
const wc = useWalletConnectLogin();
const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState("");
const [mobileDevice, setMobileDevice] = useState(false);
const [selectedWallet, setSelectedWallet] = useState<WalletKind | null>(null);
const [hasInjected, setHasInjected] = useState(false);
const [showOther, setShowOther] = useState(false);
const [openingWallet, setOpeningWallet] = useState<WalletKind | null>(null);
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>(
null,
);
useEffect(() => {
if (!loginModalOpen) return;
setMobileDevice(isMobileDevice());
setSelectedWallet(null);
setHasInjected(Boolean(getInjectedEthereum()));
setShowOther(false);
setOpeningWallet(null);
setTpRequest(null);
setState("idle");
setError("");
}, [loginModalOpen]);
wc.reset();
}, [loginModalOpen, wc]);
useEffect(() => {
if (!loginModalOpen || !tpRequest || state !== "tpPolling") return;
let cancelled = false;
const abortController = new AbortController();
const poll = async () => {
try {
const result = await fetchTokenPocketLoginResult(
tpRequest.actionId,
abortController.signal,
);
if (cancelled) return;
if (result.status === "completed") {
const verified = await verifyWalletSignature({
address: result.address,
message: result.message,
signature: result.signature,
});
if (cancelled) return;
completeLogin(verified.token, verified.wallet);
setState("idle");
setTpRequest(null);
return;
}
if (result.status === "expired" || result.status === "failed") {
setState("idle");
setError(result.error || t("walletTpExpired"));
}
} catch (err) {
if (
!cancelled &&
!(err instanceof DOMException && err.name === "AbortError")
) {
setError(t("walletLoginFailed"));
}
}
};
void poll();
const timer = window.setInterval(() => void poll(), pollIntervalMs);
return () => {
cancelled = true;
abortController.abort();
window.clearInterval(timer);
};
}, [completeLogin, loginModalOpen, state, t, tpRequest]);
const busy = state === "signing" || state === "tpLoading";
const close = () => {
if (state === "signing") return;
if (busy) return;
closeLoginModal();
setError("");
};
if (!loginModalOpen) return null;
const walletName = (kind: WalletKind) => {
if (kind === "tokenPocket") return t("walletTokenPocket");
if (kind === "metaMask") return t("walletMetaMask");
return t("walletImToken");
};
const withWallet = (key: string, kind: WalletKind) =>
t(key).replace("{wallet}", t(walletNameKey(kind)));
const chooseWallet = async (kind: WalletKind) => {
const signInjected = async () => {
setError("");
setSelectedWallet(kind);
const injectedWallet = getInjectedWallet(kind);
if (!injectedWallet) {
if (mobileDevice) {
openWalletDeepLink(kind);
return;
}
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return;
}
setState("signing");
await signInInjected(kind)
await signInInjected()
.catch((err) => {
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
})
.finally(() => setState("idle"));
};
const openApp = (kind: WalletKind) => {
setError("");
setOpeningWallet(kind);
openWalletDeepLink(kind);
};
const startTokenPocketQr = async () => {
setError("");
setState("tpLoading");
try {
const req = await createTokenPocketLoginRequest();
setTpRequest(req);
setState("tpPolling");
} catch {
setState("idle");
setError(t("walletTpQrFailed"));
}
};
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"
@@ -111,46 +189,157 @@ export function WalletLoginModal() {
</div>
<div className="mt-5 grid gap-3">
<p className="text-sm font-medium text-neutral-300">
{mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")}
</p>
<div className="grid gap-2">
{walletOptions.map((option) => {
const signingThis =
state === "signing" && selectedWallet === option.kind;
return (
{!mobileDevice || hasInjected ? (
<>
<button
key={option.kind}
type="button"
onClick={() => void chooseWallet(option.kind)}
disabled={state === "signing"}
className="flex items-center justify-between 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 disabled:cursor-wait disabled:opacity-70"
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"
>
<span>{t(option.labelKey)}</span>
<span className="text-sm text-ark-gold">
{signingThis
{state === "signing"
? t("walletSigning")
: mobileDevice
? t("walletOpen")
: t("walletConnect")}
</span>
? t("walletUseCurrent")
: t("walletInjected")}
</button>
);
})}
</div>
{!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")}
</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"
>
<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>
)}
<div className="rounded-2xl border border-dashed border-white/15">
<button
type="button"
onClick={() => setShowOther((value) => !value)}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold text-ark-gold transition hover:text-ark-gold2"
>
<span>{t("walletOtherMethods")}</span>
<span>{showOther ? "" : "+"}</span>
</button>
{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>
{error ? (
{/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */}
<div className="grid gap-2 border-t border-white/10 pt-4">
<p className="text-sm font-semibold text-neutral-200">
{t("walletRainbowFallback")}
</p>
<p className="text-xs leading-5 text-neutral-400">
{t("walletRainbowFallbackDesc")}
</p>
{wc.available ? (
<>
<button
type="button"
onClick={() => wc.start()}
disabled={wc.state !== "idle"}
className="justify-self-start 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"
>
{wc.state === "connecting"
? t("walletConnecting")
: wc.state === "signing"
? t("walletSigning")
: t("walletOpenRainbow")}
</button>
<p className="text-xs leading-5 text-amber-300/80">
{t("walletNetworkWarning")}
</p>
</>
) : (
<p className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs leading-5 text-neutral-500">
{t("walletRainbowUnavailable")}
</p>
)}
</div>
</div>
) : null}
</div>
</div>
{error || wc.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}
{error || wc.error}
</p>
) : null}
</div>
</div>
);
}
function walletNameKey(kind: WalletKind): string {
if (kind === "tokenPocket") return "walletTokenPocket";
if (kind === "metaMask") return "walletMetaMask";
return "walletImToken";
}

View File

@@ -29,3 +29,13 @@ export function openWalletDeepLink(kind: WalletKind): void {
if (typeof window === "undefined") return;
window.location.href = walletDeepLink(kind);
}
const downloadUrls: Record<WalletKind, string> = {
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];
}

View File

@@ -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<void> {
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<string[]>({
method: "eth_requestAccounts",
});

View File

@@ -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<WalletConnectLoginState>("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 };
}