terry-wallet-login #15
@@ -11,7 +11,12 @@ import {
|
|||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useWallet } from "../wallet/WalletProvider";
|
import { useWallet } from "../wallet/WalletProvider";
|
||||||
import { addFavorite, getFavoriteIds, removeFavorite } from "./api";
|
import {
|
||||||
|
addFavorite,
|
||||||
|
getFavoriteIds,
|
||||||
|
isFavoritesAuthError,
|
||||||
|
removeFavorite,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
|
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
|
||||||
|
|
||||||
@@ -30,7 +35,13 @@ export function FavoritesProvider({ children }: { children: ReactNode }) {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const wallet = useWallet();
|
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 [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
|
||||||
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
|
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
|
||||||
const [pendingIds, setPendingIds] = 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));
|
ids.forEach((id) => next.add(id));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isFavoritesAuthError(error)) handleAuthError();
|
||||||
} finally {
|
} finally {
|
||||||
requestIds.forEach((id) => inFlightIdsRef.current.delete(id));
|
requestIds.forEach((id) => inFlightIdsRef.current.delete(id));
|
||||||
if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) {
|
if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) {
|
||||||
@@ -122,7 +135,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [handleAuthError]);
|
||||||
|
|
||||||
const ensureFavoriteIds = useCallback(
|
const ensureFavoriteIds = useCallback(
|
||||||
async (resourceIds: string[]) => {
|
async (resourceIds: string[]) => {
|
||||||
@@ -158,7 +171,8 @@ export function FavoritesProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
markFavorite(resourceId, currentlyFavorite);
|
markFavorite(resourceId, currentlyFavorite);
|
||||||
showToast(t("favoriteFailed"), "error");
|
if (isFavoritesAuthError(error)) handleAuthError();
|
||||||
|
else showToast(t("favoriteFailed"), "error");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setPendingIds((prev) => {
|
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(
|
const toggleFavorite = useCallback(
|
||||||
|
|||||||
@@ -30,8 +30,23 @@ function authHeaders(token: string): HeadersInit {
|
|||||||
return { Authorization: `Bearer ${token}` };
|
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> {
|
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>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { AnimatePresence, m } from "framer-motion";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
||||||
@@ -637,6 +643,19 @@ export function PublicLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none">
|
<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">
|
<div ref={desktopSearchRef} className="hidden md:block">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -722,6 +741,15 @@ export function PublicLayout() {
|
|||||||
>
|
>
|
||||||
{t("popular")}
|
{t("popular")}
|
||||||
</Link>
|
</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">
|
<div className="mt-2 w-full max-w-xs">
|
||||||
<WalletButton compact onOpenLogin={() => setOpen(false)} />
|
<WalletButton compact onOpenLogin={() => setOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const enDict: Dict = {
|
|||||||
walletDisconnect: "Disconnect",
|
walletDisconnect: "Disconnect",
|
||||||
walletLoginTitle: "Connect wallet",
|
walletLoginTitle: "Connect wallet",
|
||||||
walletLoginDesc:
|
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",
|
walletInjected: "Use browser wallet",
|
||||||
walletInjectedDesc: "Sign with the wallet available in this browser.",
|
walletInjectedDesc: "Sign with the wallet available in this browser.",
|
||||||
walletNoBrowserWallet: "No browser wallet detected",
|
walletNoBrowserWallet: "No browser wallet detected",
|
||||||
@@ -183,14 +183,19 @@ export const enDict: Dict = {
|
|||||||
walletTokenPocket: "TokenPocket",
|
walletTokenPocket: "TokenPocket",
|
||||||
walletMetaMask: "MetaMask",
|
walletMetaMask: "MetaMask",
|
||||||
walletImToken: "imToken",
|
walletImToken: "imToken",
|
||||||
|
favoritesFilters: "Filters",
|
||||||
|
favoriteSessionExpired: "Your session expired. Please sign in again.",
|
||||||
walletChooseDesktop:
|
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.",
|
walletChooseMobile: "Choose a wallet app to open this site.",
|
||||||
walletDesktopHint:
|
walletDesktopHint:
|
||||||
"If no wallet opens after clicking, make sure the matching browser extension is installed and enabled.",
|
"If no wallet opens after clicking, make sure the matching browser extension is installed and enabled.",
|
||||||
walletInstallSelected:
|
walletInstallSelected:
|
||||||
"No {wallet} browser extension detected. Install or enable it, then try again.",
|
"No {wallet} browser extension detected. Install or enable it, then try again.",
|
||||||
walletOpen: "Open",
|
walletOpen: "Open",
|
||||||
|
walletQrLogin: "QR login",
|
||||||
|
walletMobileQrDesc:
|
||||||
|
"Use another device to scan this QR code and log in to this browser.",
|
||||||
walletTokenPocketQr: "TokenPocket QR login",
|
walletTokenPocketQr: "TokenPocket QR login",
|
||||||
walletTokenPocketQrDesc:
|
walletTokenPocketQrDesc:
|
||||||
"Recommended for China users. Scan with TokenPocket and sign to return login to this browser.",
|
"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",
|
walletLoginSuccess: "Wallet connected",
|
||||||
walletLoginFailed: "Wallet login failed",
|
walletLoginFailed: "Wallet login failed",
|
||||||
walletDisconnected: "Wallet disconnected",
|
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",
|
featureUnavailable: "Not available yet",
|
||||||
featureUnavailableDesc: "This feature is not available yet.",
|
featureUnavailableDesc: "This feature is not available yet.",
|
||||||
confirm: "Got it",
|
confirm: "Got it",
|
||||||
|
|||||||
@@ -171,8 +171,31 @@ export const idDict: Dict = {
|
|||||||
walletLoginTitle: "Hubungkan dompet",
|
walletLoginTitle: "Hubungkan dompet",
|
||||||
walletLoginDesc:
|
walletLoginDesc:
|
||||||
"Tanda tangani pesan untuk memverifikasi alamat dompet. Tidak ada transaksi atau gas.",
|
"Tanda tangani pesan untuk memverifikasi alamat dompet. Tidak ada transaksi atau gas.",
|
||||||
walletInjected: "Dompet browser / browser DApp",
|
walletInjected: "Gunakan dompet browser",
|
||||||
walletInjectedDesc: "Gunakan dompet yang tersedia di browser ini.",
|
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",
|
walletTokenPocketQr: "Login QR TokenPocket",
|
||||||
walletTokenPocketQrDesc:
|
walletTokenPocketQrDesc:
|
||||||
"Direkomendasikan untuk pengguna Tiongkok. Pindai dengan TokenPocket dan tanda tangani untuk login di browser ini.",
|
"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",
|
walletLoginSuccess: "Dompet terhubung",
|
||||||
walletLoginFailed: "Login dompet gagal",
|
walletLoginFailed: "Login dompet gagal",
|
||||||
walletDisconnected: "Dompet terputus",
|
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",
|
featureUnavailable: "Belum tersedia",
|
||||||
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
||||||
confirm: "Mengerti",
|
confirm: "Mengerti",
|
||||||
|
|||||||
@@ -194,6 +194,37 @@ export const jaDict: Dict = {
|
|||||||
walletLoginSuccess: "ウォレットを接続しました",
|
walletLoginSuccess: "ウォレットを接続しました",
|
||||||
walletLoginFailed: "ウォレットログインに失敗しました",
|
walletLoginFailed: "ウォレットログインに失敗しました",
|
||||||
walletDisconnected: "ウォレットを切断しました",
|
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: "未公開",
|
featureUnavailable: "未公開",
|
||||||
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
||||||
confirm: "了解",
|
confirm: "了解",
|
||||||
|
|||||||
@@ -170,8 +170,30 @@ export const koDict: Dict = {
|
|||||||
walletLoginTitle: "지갑 연결",
|
walletLoginTitle: "지갑 연결",
|
||||||
walletLoginDesc:
|
walletLoginDesc:
|
||||||
"메시지 서명으로 지갑 주소를 확인합니다. 트랜잭션이나 가스 수수료는 없습니다.",
|
"메시지 서명으로 지갑 주소를 확인합니다. 트랜잭션이나 가스 수수료는 없습니다.",
|
||||||
walletInjected: "브라우저 지갑 / DApp 브라우저",
|
walletInjected: "브라우저 지갑 사용",
|
||||||
walletInjectedDesc: "현재 브라우저에서 사용 가능한 지갑을 사용합니다.",
|
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 로그인",
|
walletTokenPocketQr: "TokenPocket QR 로그인",
|
||||||
walletTokenPocketQrDesc:
|
walletTokenPocketQrDesc:
|
||||||
"중국 사용자에게 권장됩니다. TokenPocket으로 스캔하고 서명하면 이 브라우저에서 로그인이 완료됩니다.",
|
"중국 사용자에게 권장됩니다. TokenPocket으로 스캔하고 서명하면 이 브라우저에서 로그인이 완료됩니다.",
|
||||||
@@ -193,6 +215,13 @@ export const koDict: Dict = {
|
|||||||
walletLoginSuccess: "지갑이 연결되었습니다",
|
walletLoginSuccess: "지갑이 연결되었습니다",
|
||||||
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
||||||
walletDisconnected: "지갑 연결이 해제되었습니다",
|
walletDisconnected: "지갑 연결이 해제되었습니다",
|
||||||
|
walletOtherMethods: "다른 로그인 방법",
|
||||||
|
walletUseCurrent: "현재 지갑 사용",
|
||||||
|
walletOpening: "{wallet} 열는 중…",
|
||||||
|
walletAppNotInstalled: "열리지 않으면 앱이 설치되어 있지 않을 수 있습니다.",
|
||||||
|
walletDownloadApp: "{wallet} 다운로드",
|
||||||
|
walletRetry: "다시 시도",
|
||||||
|
walletConnecting: "연결 중…",
|
||||||
featureUnavailable: "준비 중",
|
featureUnavailable: "준비 중",
|
||||||
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
||||||
confirm: "확인",
|
confirm: "확인",
|
||||||
|
|||||||
@@ -171,8 +171,31 @@ export const msDict: Dict = {
|
|||||||
walletLoginTitle: "Sambung dompet",
|
walletLoginTitle: "Sambung dompet",
|
||||||
walletLoginDesc:
|
walletLoginDesc:
|
||||||
"Tandatangani mesej untuk mengesahkan alamat dompet. Tiada transaksi atau gas.",
|
"Tandatangani mesej untuk mengesahkan alamat dompet. Tiada transaksi atau gas.",
|
||||||
walletInjected: "Dompet pelayar / pelayar DApp",
|
walletInjected: "Guna dompet pelayar",
|
||||||
walletInjectedDesc: "Gunakan dompet yang tersedia dalam pelayar ini.",
|
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",
|
walletTokenPocketQr: "Log masuk QR TokenPocket",
|
||||||
walletTokenPocketQrDesc:
|
walletTokenPocketQrDesc:
|
||||||
"Disyorkan untuk pengguna China. Imbas dengan TokenPocket dan tandatangani untuk log masuk pada pelayar ini.",
|
"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",
|
walletLoginSuccess: "Dompet disambungkan",
|
||||||
walletLoginFailed: "Log masuk dompet gagal",
|
walletLoginFailed: "Log masuk dompet gagal",
|
||||||
walletDisconnected: "Dompet diputuskan",
|
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",
|
featureUnavailable: "Belum tersedia",
|
||||||
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
||||||
confirm: "Faham",
|
confirm: "Faham",
|
||||||
|
|||||||
@@ -170,8 +170,30 @@ export const viDict: Dict = {
|
|||||||
walletLoginTitle: "Kết nối ví",
|
walletLoginTitle: "Kết nối ví",
|
||||||
walletLoginDesc:
|
walletLoginDesc:
|
||||||
"Ký tin nhắn để xác minh địa chỉ ví. Không có giao dịch hay phí gas.",
|
"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",
|
walletInjected: "Dùng ví trình duyệt",
|
||||||
walletInjectedDesc: "Dùng ví đã có trong trình duyệt hiện tại.",
|
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",
|
walletTokenPocketQr: "Đăng nhập QR TokenPocket",
|
||||||
walletTokenPocketQrDesc:
|
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.",
|
"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í",
|
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í",
|
||||||
|
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",
|
featureUnavailable: "Chưa khả dụng",
|
||||||
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
||||||
confirm: "Đã hiểu",
|
confirm: "Đã hiểu",
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const zhDict: Dict = {
|
|||||||
walletConnectedAs: "已连接钱包",
|
walletConnectedAs: "已连接钱包",
|
||||||
walletDisconnect: "断开连接",
|
walletDisconnect: "断开连接",
|
||||||
walletLoginTitle: "连接钱包",
|
walletLoginTitle: "连接钱包",
|
||||||
walletLoginDesc: "签名验证 BNB Chain 钱包地址,不会发起交易,也不需要 Gas。",
|
walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。",
|
||||||
walletInjected: "使用浏览器钱包登录",
|
walletInjected: "使用浏览器钱包登录",
|
||||||
walletInjectedDesc: "签名验证当前浏览器里的钱包。",
|
walletInjectedDesc: "签名验证当前浏览器里的钱包。",
|
||||||
walletNoBrowserWallet: "未检测到浏览器钱包",
|
walletNoBrowserWallet: "未检测到浏览器钱包",
|
||||||
@@ -174,13 +174,16 @@ export const zhDict: Dict = {
|
|||||||
walletTokenPocket: "TokenPocket",
|
walletTokenPocket: "TokenPocket",
|
||||||
walletMetaMask: "MetaMask",
|
walletMetaMask: "MetaMask",
|
||||||
walletImToken: "imToken",
|
walletImToken: "imToken",
|
||||||
walletChooseDesktop:
|
favoritesFilters: "筛选",
|
||||||
"选择你要使用的钱包。电脑端需要先安装对应浏览器插件,并切换到 BNB Chain。",
|
favoriteSessionExpired: "登录已过期,请重新登录。",
|
||||||
|
walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。",
|
||||||
walletChooseMobile: "选择钱包 App 打开本站。",
|
walletChooseMobile: "选择钱包 App 打开本站。",
|
||||||
walletDesktopHint:
|
walletDesktopHint:
|
||||||
"如果点击后没有弹出钱包,请确认已安装并启用对应的钱包浏览器插件。",
|
"如果点击后没有弹出钱包,请确认已安装并启用对应的钱包浏览器插件。",
|
||||||
walletInstallSelected: "未检测到 {wallet} 浏览器插件,请先安装或启用后再试。",
|
walletInstallSelected: "未检测到 {wallet} 浏览器插件,请先安装或启用后再试。",
|
||||||
walletOpen: "打开",
|
walletOpen: "打开",
|
||||||
|
walletQrLogin: "扫码登录",
|
||||||
|
walletMobileQrDesc: "适合用另一台设备扫描二维码登录当前浏览器。",
|
||||||
walletTokenPocketQr: "TokenPocket 扫码登录",
|
walletTokenPocketQr: "TokenPocket 扫码登录",
|
||||||
walletTokenPocketQrDesc:
|
walletTokenPocketQrDesc:
|
||||||
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",
|
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",
|
||||||
@@ -202,6 +205,13 @@ export const zhDict: Dict = {
|
|||||||
walletLoginSuccess: "钱包已连接",
|
walletLoginSuccess: "钱包已连接",
|
||||||
walletLoginFailed: "钱包登录失败",
|
walletLoginFailed: "钱包登录失败",
|
||||||
walletDisconnected: "钱包已断开",
|
walletDisconnected: "钱包已断开",
|
||||||
|
walletOtherMethods: "其他登录方式",
|
||||||
|
walletUseCurrent: "使用当前钱包登录",
|
||||||
|
walletOpening: "正在打开 {wallet}…",
|
||||||
|
walletAppNotInstalled: "如果没有跳转,可能是未安装该 App。",
|
||||||
|
walletDownloadApp: "下载 {wallet}",
|
||||||
|
walletRetry: "重试",
|
||||||
|
walletConnecting: "连接中…",
|
||||||
featureUnavailable: "未开放",
|
featureUnavailable: "未开放",
|
||||||
featureUnavailableDesc: "该功能暂未开放。",
|
featureUnavailableDesc: "该功能暂未开放。",
|
||||||
confirm: "知道了",
|
confirm: "知道了",
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
type Resource,
|
type Resource,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
import { FavoriteButton } from "../../favorites/FavoriteButton";
|
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 { useFavorites } from "../../favorites/FavoritesProvider";
|
||||||
import { langQuery, useI18n, type Lang } from "../../i18n";
|
import { langQuery, useI18n, type Lang } from "../../i18n";
|
||||||
import { homePathForLang } from "../../languageRoutes";
|
import { homePathForLang } from "../../languageRoutes";
|
||||||
@@ -134,6 +138,8 @@ export default function Favorites() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
useSetPageTitle(t("favorites"));
|
useSetPageTitle(t("favorites"));
|
||||||
|
|
||||||
@@ -167,8 +173,13 @@ export default function Favorites() {
|
|||||||
resources.forEach((resource) => markFavorite(resource.id, true));
|
resources.forEach((resource) => markFavorite(resource.id, true));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled)
|
if (cancelled) return;
|
||||||
setError(err instanceof Error ? err.message : t("loadFailed"));
|
if (isFavoritesAuthError(err)) {
|
||||||
|
wallet.logout();
|
||||||
|
wallet.openLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : t("loadFailed"));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -176,17 +187,7 @@ export default function Favorites() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [
|
}, [category, lang, markFavorite, page, query, reloadKey, sort, t, wallet]);
|
||||||
category,
|
|
||||||
lang,
|
|
||||||
markFavorite,
|
|
||||||
page,
|
|
||||||
query,
|
|
||||||
sort,
|
|
||||||
t,
|
|
||||||
wallet.status,
|
|
||||||
wallet.token,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const hasFilters = Boolean(category || query || sort !== "favorited_at");
|
const hasFilters = Boolean(category || query || sort !== "favorited_at");
|
||||||
@@ -260,33 +261,50 @@ export default function Favorites() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="relative block">
|
{/* Mobile-only toggle: collapse sort/category into a "Filters" drawer. */}
|
||||||
<SlidersHorizontal className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
|
<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)
|
||||||
|
}
|
||||||
|
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) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={sort}
|
value={category}
|
||||||
onChange={(event) => setSort(event.target.value as FavoriteSort)}
|
onChange={(event) => setCategory(event.target.value)}
|
||||||
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"
|
className="h-11 w-full rounded-full border border-white/10 bg-[#101016] px-4 text-sm text-white outline-none focus:border-ark-gold/60"
|
||||||
>
|
>
|
||||||
{sortOptions.map((option) => (
|
<option value="">{t("favoritesFilterAllCategories")}</option>
|
||||||
<option key={option.value} value={option.value}>
|
{categories.map((cat) => (
|
||||||
{option.label}
|
<option key={cat.slug} value={cat.slug}>
|
||||||
|
{cleanCategoryDisplayName(cat.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<select
|
|
||||||
value={category}
|
|
||||||
onChange={(event) => setCategory(event.target.value)}
|
|
||||||
className="h-11 w-full rounded-full border border-white/10 bg-[#101016] px-4 text-sm text-white outline-none focus:border-ark-gold/60"
|
|
||||||
>
|
|
||||||
<option value="">{t("favoritesFilterAllCategories")}</option>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat.slug} value={cat.slug}>
|
|
||||||
{cleanCategoryDisplayName(cat.name)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -320,8 +338,16 @@ export default function Favorites() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
<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">
|
||||||
{error}
|
<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>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : 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">
|
<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">
|
||||||
|
|||||||
33
src/wallet/WalletBrandIcon.tsx
Normal file
33
src/wallet/WalletBrandIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Heart } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { shortenAddress, useWallet } from "./WalletProvider";
|
import { shortenAddress, useWallet } from "./WalletProvider";
|
||||||
|
|
||||||
export function WalletButton({
|
export function WalletButton({
|
||||||
@@ -10,6 +13,7 @@ export function WalletButton({
|
|||||||
onOpenLogin?: () => void;
|
onOpenLogin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const wallet = useWallet();
|
const wallet = useWallet();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -53,6 +57,14 @@ export function WalletButton({
|
|||||||
<div className="truncate px-3 py-2 text-xs text-neutral-400">
|
<div className="truncate px-3 py-2 text-xs text-neutral-400">
|
||||||
{wallet.address}
|
{wallet.address}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,86 +1,164 @@
|
|||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { openWalletDeepLink } from "./deepLinks";
|
import {
|
||||||
import { getInjectedWallet, type WalletKind } from "./injected";
|
createTokenPocketLoginRequest,
|
||||||
|
fetchTokenPocketLoginResult,
|
||||||
|
verifyWalletSignature,
|
||||||
|
type TokenPocketLoginRequest,
|
||||||
|
} from "./api";
|
||||||
|
import { openWalletDeepLink, walletDownloadUrl } from "./deepLinks";
|
||||||
|
import { getInjectedEthereum, type WalletKind } from "./injected";
|
||||||
import { useWallet } from "./WalletProvider";
|
import { useWallet } from "./WalletProvider";
|
||||||
|
import { useWalletConnectLogin } from "./useWalletConnectLogin";
|
||||||
|
import { WalletBrandIcon } from "./WalletBrandIcon";
|
||||||
|
|
||||||
type ModalState = "idle" | "signing";
|
const pollIntervalMs = 1800;
|
||||||
|
|
||||||
type WalletOption = {
|
type ModalState = "idle" | "signing" | "tpLoading" | "tpPolling";
|
||||||
kind: WalletKind;
|
|
||||||
labelKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const walletOptions: WalletOption[] = [
|
const appWallets: { kind: WalletKind; labelKey: string }[] = [
|
||||||
{ kind: "tokenPocket", labelKey: "walletTokenPocket" },
|
{ kind: "tokenPocket", labelKey: "walletOpenTokenPocket" },
|
||||||
{ kind: "metaMask", labelKey: "walletMetaMask" },
|
{ kind: "metaMask", labelKey: "walletOpenMetaMask" },
|
||||||
{ kind: "imToken", labelKey: "walletImToken" },
|
{ kind: "imToken", labelKey: "walletOpenImToken" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isMobileDevice(): boolean {
|
function isMobileDevice(): boolean {
|
||||||
if (typeof navigator === "undefined") return false;
|
if (typeof navigator === "undefined") return false;
|
||||||
const ua = navigator.userAgent || "";
|
const ua = navigator.userAgent || "";
|
||||||
return (
|
if (
|
||||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||||
ua,
|
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() {
|
export function WalletLoginModal() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
|
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
|
||||||
|
useWallet();
|
||||||
|
const wc = useWalletConnectLogin();
|
||||||
const [state, setState] = useState<ModalState>("idle");
|
const [state, setState] = useState<ModalState>("idle");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [mobileDevice, setMobileDevice] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!loginModalOpen) return;
|
if (!loginModalOpen) return;
|
||||||
setMobileDevice(isMobileDevice());
|
setMobileDevice(isMobileDevice());
|
||||||
setSelectedWallet(null);
|
setHasInjected(Boolean(getInjectedEthereum()));
|
||||||
|
setShowOther(false);
|
||||||
|
setOpeningWallet(null);
|
||||||
|
setTpRequest(null);
|
||||||
|
setState("idle");
|
||||||
setError("");
|
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 = () => {
|
const close = () => {
|
||||||
if (state === "signing") return;
|
if (busy) return;
|
||||||
closeLoginModal();
|
closeLoginModal();
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loginModalOpen) return null;
|
if (!loginModalOpen) return null;
|
||||||
|
|
||||||
const walletName = (kind: WalletKind) => {
|
const withWallet = (key: string, kind: WalletKind) =>
|
||||||
if (kind === "tokenPocket") return t("walletTokenPocket");
|
t(key).replace("{wallet}", t(walletNameKey(kind)));
|
||||||
if (kind === "metaMask") return t("walletMetaMask");
|
|
||||||
return t("walletImToken");
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseWallet = async (kind: WalletKind) => {
|
const signInjected = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setSelectedWallet(kind);
|
|
||||||
|
|
||||||
const injectedWallet = getInjectedWallet(kind);
|
|
||||||
if (!injectedWallet) {
|
|
||||||
if (mobileDevice) {
|
|
||||||
openWalletDeepLink(kind);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(
|
|
||||||
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState("signing");
|
setState("signing");
|
||||||
await signInInjected(kind)
|
await signInInjected()
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
|
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
|
||||||
})
|
})
|
||||||
.finally(() => setState("idle"));
|
.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 (
|
return (
|
||||||
<div
|
<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"
|
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>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3">
|
<div className="mt-5 grid gap-3">
|
||||||
<p className="text-sm font-medium text-neutral-300">
|
{!mobileDevice || hasInjected ? (
|
||||||
{mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")}
|
<>
|
||||||
</p>
|
<button
|
||||||
<div className="grid gap-2">
|
type="button"
|
||||||
{walletOptions.map((option) => {
|
onClick={() => void signInjected()}
|
||||||
const signingThis =
|
disabled={busy}
|
||||||
state === "signing" && selectedWallet === option.kind;
|
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"
|
||||||
return (
|
>
|
||||||
|
{state === "signing"
|
||||||
|
? t("walletSigning")
|
||||||
|
: mobileDevice
|
||||||
|
? t("walletUseCurrent")
|
||||||
|
: t("walletInjected")}
|
||||||
|
</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")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-300">
|
||||||
|
{t("walletOpenWalletApp")}
|
||||||
|
</p>
|
||||||
|
{appWallets.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.kind}
|
key={option.kind}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void chooseWallet(option.kind)}
|
onClick={() => openApp(option.kind)}
|
||||||
disabled={state === "signing"}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
|
<WalletBrandIcon kind={option.kind} />
|
||||||
<span>{t(option.labelKey)}</span>
|
<span>{t(option.labelKey)}</span>
|
||||||
<span className="text-sm text-ark-gold">
|
|
||||||
{signingThis
|
|
||||||
? t("walletSigning")
|
|
||||||
: mobileDevice
|
|
||||||
? t("walletOpen")
|
|
||||||
: t("walletConnect")}
|
|
||||||
</span>
|
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
{!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>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{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">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function walletNameKey(kind: WalletKind): string {
|
||||||
|
if (kind === "tokenPocket") return "walletTokenPocket";
|
||||||
|
if (kind === "metaMask") return "walletMetaMask";
|
||||||
|
return "walletImToken";
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,13 @@ export function openWalletDeepLink(kind: WalletKind): void {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.location.href = walletDeepLink(kind);
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { requestWalletNonce, verifyWalletSignature } from "./api";
|
|||||||
|
|
||||||
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
||||||
|
|
||||||
const bnbChainIdHex = "0x38";
|
|
||||||
|
|
||||||
export type EthereumProvider = {
|
export type EthereumProvider = {
|
||||||
isMetaMask?: boolean;
|
isMetaMask?: boolean;
|
||||||
isTokenPocket?: boolean;
|
isTokenPocket?: boolean;
|
||||||
@@ -36,30 +34,6 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null {
|
|||||||
return match ?? 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<{
|
export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
||||||
token: string;
|
token: string;
|
||||||
wallet: string;
|
wallet: string;
|
||||||
@@ -67,8 +41,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
|||||||
const ethereum = getInjectedWallet(kind);
|
const ethereum = getInjectedWallet(kind);
|
||||||
if (!ethereum) throw new Error("No injected wallet found");
|
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[]>({
|
const accounts = await ethereum.request<string[]>({
|
||||||
method: "eth_requestAccounts",
|
method: "eth_requestAccounts",
|
||||||
});
|
});
|
||||||
|
|||||||
84
src/wallet/useWalletConnectLogin.ts
Normal file
84
src/wallet/useWalletConnectLogin.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user