terry-staging #16

Merged
terry merged 96 commits from terry-staging into main 2026-06-05 16:33:12 +00:00
3 changed files with 219 additions and 256 deletions
Showing only changes of commit 803d3d57c1 - Show all commits

View File

@@ -806,7 +806,7 @@ export function PublicLayout() {
</main> </main>
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden"> <nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
<div className="grid h-[68px] grid-cols-3 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]"> <div className="grid h-[68px] grid-cols-4 gap-2 px-4 py-[10px] text-center text-[11px] leading-[17.6px]">
<BottomNavIcon <BottomNavIcon
to={homePath} to={homePath}
label={t("home")} label={t("home")}
@@ -822,6 +822,12 @@ export function PublicLayout() {
!new URLSearchParams(search).get("sort") !new URLSearchParams(search).get("sort")
} }
/> />
<BottomNavIcon
to={lp("/favorites")}
label={t("favorites")}
icon="bookmark"
active={na("favorites")}
/>
<BottomNavIcon <BottomNavIcon
to={popularHref} to={popularHref}
label={t("popular")} label={t("popular")}

View File

@@ -1,47 +1,26 @@
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react"; import { LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import type { WalletKind } from "./injected";
import { openWalletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin"; import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon"; import { WalletBrandIcon } from "./WalletBrandIcon";
type ModalState = "idle" | "signing";
type Step = "wallet" | "method";
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"]; const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false; if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || ""; return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
if ( navigator.userAgent || "",
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test( );
ua,
)
) {
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, loginModalOpen } = useWallet();
const wc = useWalletConnectLogin(); const wc = useWalletConnectLogin();
const [step, setStep] = useState<Step>("wallet");
const [selected, setSelected] = useState<WalletKind | null>(null); const [selected, setSelected] = useState<WalletKind | null>(null);
const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState("");
const [mobileDevice, setMobileDevice] = useState(false); const [mobileDevice, setMobileDevice] = useState(false);
const resetWalletConnect = wc.reset; const resetWalletConnect = wc.reset;
@@ -49,74 +28,34 @@ export function WalletLoginModal() {
useEffect(() => { useEffect(() => {
if (!loginModalOpen) return; if (!loginModalOpen) return;
setMobileDevice(isMobileDevice()); setMobileDevice(isMobileDevice());
setStep("wallet");
setSelected(null); setSelected(null);
setState("idle");
setError("");
resetWalletConnect(); resetWalletConnect();
}, [loginModalOpen, resetWalletConnect]); }, [loginModalOpen, resetWalletConnect]);
const busy = state === "signing";
const close = () => {
if (busy) return;
closeLoginModal();
setError("");
};
if (!loginModalOpen) return null; if (!loginModalOpen) return null;
const walletName = (kind: WalletKind) => t(walletNameKey(kind)); const walletName = (kind: WalletKind) => t(walletNameKey(kind));
// A wallet's browser flow only works if THAT wallet is injected right now const walletHint = (kind: WalletKind) => {
// (its desktop extension, or its in-app browser on mobile). if (kind === "tokenPocket") {
const injectedAvailable = (kind: WalletKind) => return mobileDevice
Boolean(getInjectedWallet(kind)); ? t("walletTpMobileDesc")
// All scan/app login paths use RainbowKit/WalletConnect on BNB Chain. : t("walletTokenPocketQrDesc");
const qrAvailable = () => wc.available; }
return t("walletRainbowFallbackDesc");
};
const busy = wc.state !== "idle";
const resetFlow = () => { const close = () => {
setError(""); closeLoginModal();
setState("idle"); setSelected(null);
wc.reset(); wc.reset();
}; };
const pickWallet = (kind: WalletKind) => { const startWalletLogin = (kind: WalletKind) => {
resetFlow();
setSelected(kind); setSelected(kind);
setStep("method");
};
const back = () => {
if (busy) return;
resetFlow();
setSelected(null);
setStep("wallet");
};
const signInjectedFor = async (kind: WalletKind) => {
setError("");
if (!getInjectedWallet(kind)) {
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return;
}
setState("signing");
await signInInjected(kind)
.catch((err) => {
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
})
.finally(() => setState("idle"));
};
const chooseQr = (kind: WalletKind) => {
setError("");
void wc.start(kind); void wc.start(kind);
}; };
const iconButtonClass =
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold";
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"
@@ -126,191 +65,91 @@ export function WalletLoginModal() {
> >
<div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6"> <div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2"> <div className="min-w-0">
{step === "method" ? ( <h2
<button id="wallet-login-title"
type="button" className="text-xl font-semibold text-white"
onClick={back} >
aria-label={t("walletBack")} {t("walletLoginTitle")}
className={`${iconButtonClass} mt-0.5`} </h2>
> <p className="mt-2 text-sm leading-6 text-neutral-400">
<ChevronLeft size={18} /> {t("walletLoginDesc")}
</button> </p>
) : null}
<div className="min-w-0">
<h2
id="wallet-login-title"
className="text-xl font-semibold text-white"
>
{t("walletLoginTitle")}
</h2>
<p className="mt-2 text-sm leading-6 text-neutral-400">
{step === "wallet"
? t("walletLoginDesc")
: t("walletChooseMethod")}
</p>
</div>
</div> </div>
<button <button
type="button" type="button"
onClick={close} onClick={close}
aria-label={t("close")} aria-label={t("close")}
className={iconButtonClass} className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Step 1 — choose a wallet. */} <div className="mt-5 grid gap-2">
{step === "wallet" ? ( {wallets.map((kind) => {
<div className="mt-5 grid gap-2"> const active = selected === kind;
{wallets.map((kind) => ( const connecting = active && wc.state === "connecting";
const signing = active && wc.state === "signing";
return (
<button <button
key={kind} key={kind}
type="button" type="button"
onClick={() => pickWallet(kind)} onClick={() => startWalletLogin(kind)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10" disabled={!wc.available || busy}
className={`flex items-center gap-3 rounded-2xl border px-4 py-4 text-left transition ${
active
? "border-ark-gold/60 bg-ark-gold/10"
: "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
} disabled:cursor-wait disabled:opacity-70`}
> >
<WalletBrandIcon kind={kind} size={32} /> <WalletBrandIcon kind={kind} size={32} />
<span className="flex-1 text-base font-semibold text-neutral-100"> <span className="min-w-0 flex-1">
{walletName(kind)} <span className="block text-base font-semibold text-neutral-100">
</span> {walletName(kind)}
<ChevronRight size={18} className="text-neutral-500" />
</button>
))}
</div>
) : selected ? (
<div className="mt-5 grid gap-3">
{/* Selected wallet header. */}
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<WalletBrandIcon kind={selected} size={28} />
<span className="text-base font-semibold text-white">
{walletName(selected)}
</span>
</div>
{!mobileDevice && !injectedAvailable(selected) ? (
<p className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-100">
{t("walletInstallSelected").replace(
"{wallet}",
walletName(selected),
)}
</p>
) : null}
{mobileDevice ? (
<button
type="button"
onClick={() => openWalletDeepLink(selected)}
className="flex flex-col items-start gap-1 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
>
<span className="text-base font-semibold text-neutral-100">
{t("walletOpenWalletApp")}
</span>
<span className="text-xs leading-5 text-neutral-400">
{t("walletOpenWalletAppDesc")}
</span>
</button>
) : null}
{/* Method: browser wallet (injected). Hidden on normal mobile browsers,
because only wallet in-app browsers expose an injected wallet. */}
{(() => {
const ok = injectedAvailable(selected);
if (mobileDevice) return null;
return (
<button
type="button"
onClick={() => void signInjectedFor(selected)}
disabled={!ok || busy}
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
>
<span className="flex items-center gap-2 text-base font-semibold text-neutral-100">
{state === "signing" ? (
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null}
{mobileDevice ? t("walletUseCurrent") : t("walletInjected")}
</span> </span>
<span className="text-xs leading-5 text-neutral-400"> <span className="mt-1 block text-xs leading-5 text-neutral-400">
{ok {connecting
? t("walletInjectedDesc") ? t("walletConnecting")
: mobileDevice : signing
? t("walletOpenWalletAppDesc") ? t("walletSigning")
: t("walletInstallSelected").replace( : walletHint(kind)}
"{wallet}",
walletName(selected),
)}
</span> </span>
</button> </span>
); {connecting || signing ? (
})()} <LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null}
</button>
);
})}
</div>
{/* Method: scan to log in. */} {!wc.available ? (
{(() => { <p className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-neutral-400">
const ok = qrAvailable(); {t("walletRainbowUnavailable")}
const isTp = selected === "tokenPocket"; </p>
const qrBusy = wc.state !== "idle"; ) : null}
const qrLabel =
wc.state === "connecting"
? t("walletConnecting")
: wc.state === "signing"
? t("walletSigning")
: t("walletQrLogin");
return (
<div className="grid gap-2">
<button
type="button"
onClick={() => chooseQr(selected)}
disabled={!ok || qrBusy || busy}
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
ok
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
}`}
>
<span className="text-base font-semibold text-neutral-100">
{qrLabel}
</span>
<span className="text-xs leading-5 text-neutral-400">
{ok
? isTp
? mobileDevice
? t("walletTpMobileDesc")
: t("walletTokenPocketQrDesc")
: t("walletRainbowFallbackDesc")
: t("walletRainbowUnavailable")}
</span>
</button>
{ok ? ( {selected && wc.qrUri ? (
<p className="text-xs leading-5 text-amber-300/80"> <div className="mt-4 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
{t("walletNetworkWarning")} <QRCodeSVG value={wc.qrUri} size={180} level="M" />
</p> <p className="text-xs font-medium text-neutral-700">
) : null} {mobileDevice
? t("walletTpWaiting")
{wc.qrUri ? ( : t("walletQrUseAnotherDevice")}
<div className="grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center"> </p>
<QRCodeSVG value={wc.qrUri} size={180} level="M" />
<p className="text-xs font-medium text-neutral-700">
{mobileDevice
? t("walletTpWaiting")
: t("walletQrUseAnotherDevice")}
</p>
</div>
) : null}
</div>
);
})()}
</div> </div>
) : null} ) : null}
{error || wc.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 || wc.error} {wc.error}
</p>
) : null}
{selected ? (
<p className="mt-4 text-xs leading-5 text-amber-300/80">
{t("walletNetworkWarning")}
</p> </p>
) : null} ) : null}
</div> </div>

View File

@@ -2,6 +2,15 @@ import { requestWalletNonce, verifyWalletSignature } from "./api";
export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
const BNB_CHAIN_ID_HEX = "0x38";
const BNB_CHAIN_PARAMS = {
chainId: BNB_CHAIN_ID_HEX,
chainName: "BNB Smart Chain",
nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 },
rpcUrls: ["https://bsc-dataseed.binance.org"],
blockExplorerUrls: ["https://bscscan.com"],
};
export type EthereumProvider = { export type EthereumProvider = {
isMetaMask?: boolean; isMetaMask?: boolean;
isTokenPocket?: boolean; isTokenPocket?: boolean;
@@ -13,6 +22,116 @@ export type EthereumProvider = {
}) => Promise<T>; }) => Promise<T>;
}; };
function isAddress(value: unknown): value is string {
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
}
function utf8ToHex(value: string): string {
return `0x${Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("")}`;
}
function errorText(error: unknown): string {
if (!error || typeof error !== "object") return String(error ?? "");
const parts: string[] = [];
const record = error as Record<string, unknown>;
for (const key of ["shortMessage", "message", "details"]) {
const value = record[key];
if (typeof value === "string") parts.push(value);
}
if (record.cause) parts.push(errorText(record.cause));
return parts.join("\n");
}
function isNoAccountError(error: unknown): boolean {
return /wallet must has at least one account|wallet must has one account|must have at least one account|no wallet account returned/i.test(
errorText(error),
);
}
function normalizeWalletError(error: unknown): Error {
if (isNoAccountError(error)) return new Error("walletNoAccount");
if (error instanceof Error) return error;
const message = errorText(error);
return new Error(message || "Wallet login failed");
}
function shouldRetryPersonalSign(error: unknown): boolean {
const text = errorText(error);
return /wallet must has at least one account|wallet must has one account|must have at least one account|invalid params|invalid account|account not found/i.test(
text,
);
}
async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
const chainId = await ethereum
.request<string>({ method: "eth_chainId" })
.catch(() => "");
if (chainId.toLowerCase() === BNB_CHAIN_ID_HEX) return;
try {
await ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: BNB_CHAIN_ID_HEX }],
});
} catch (error) {
const code = (error as { code?: number | string } | null)?.code;
if (code !== 4902 && code !== "4902") throw error;
await ethereum.request({
method: "wallet_addEthereumChain",
params: [BNB_CHAIN_PARAMS],
});
}
}
async function requestInjectedAddress(
ethereum: EthereumProvider,
): Promise<string> {
const existingAccounts: unknown[] = await ethereum
.request<unknown[]>({ method: "eth_accounts" })
.catch((): unknown[] => []);
const existingAddress = existingAccounts.find(isAddress);
if (existingAddress) return existingAddress;
const requestedAccounts = await ethereum
.request<unknown[]>({
method: "eth_requestAccounts",
})
.catch((error: unknown): never => {
throw normalizeWalletError(error);
});
const requestedAddress = requestedAccounts.find(isAddress);
if (!requestedAddress) throw new Error("walletNoAccount");
return requestedAddress;
}
async function personalSign(params: {
ethereum: EthereumProvider;
message: string;
address: string;
}): Promise<string> {
const { ethereum, message, address } = params;
const hexMessage = utf8ToHex(message);
try {
return await ethereum.request<string>({
method: "personal_sign",
params: [hexMessage, address],
});
} catch (error) {
if (!shouldRetryPersonalSign(error)) throw error;
// Some injected wallets incorrectly expect the legacy param order.
return ethereum
.request<string>({
method: "personal_sign",
params: [address, hexMessage],
})
.catch((retryError: unknown): never => {
throw normalizeWalletError(retryError);
});
}
}
export function getInjectedEthereum(): EthereumProvider | null { export function getInjectedEthereum(): EthereumProvider | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider }; const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
@@ -69,23 +188,22 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
throw new Error("No injected wallet found"); throw new Error("No injected wallet found");
} }
// Login is signature-only (EIP-191 personal_sign). The backend verifies the // BNB Smart Chain is EVM-compatible, so browser wallets still expose the
// recovered address and never inspects chainId, so we deliberately do NOT // standard EIP-1193 method names (`eth_*`) while operating on BNB chain 56.
// switch or add any chain — that only adds a failure-prone wallet popup. console.info("[wallet-login] requesting BNB wallet account…");
console.info("[wallet-login] requesting accounts (eth_requestAccounts)…"); const address = await requestInjectedAddress(ethereum);
const accounts = await ethereum.request<string[]>({ console.info("[wallet-login] account", address);
method: "eth_requestAccounts",
}); console.info("[wallet-login] ensuring BNB Chain (0x38)…");
console.info("[wallet-login] accounts", accounts); await ensureBnbChain(ethereum);
const address = accounts[0];
if (!address) throw new Error("No wallet account returned");
console.info("[wallet-login] requesting nonce for", address); console.info("[wallet-login] requesting nonce for", address);
const nonce = await requestWalletNonce(address); const nonce = await requestWalletNonce(address);
console.info("[wallet-login] got nonce, requesting personal_sign…"); console.info("[wallet-login] got nonce, requesting personal_sign…");
const signature = await ethereum.request<string>({ const signature = await personalSign({
method: "personal_sign", ethereum,
params: [nonce.message, address], message: nonce.message,
address,
}); });
console.info("[wallet-login] signed, verifying with backend…"); console.info("[wallet-login] signed, verifying with backend…");
const result = await verifyWalletSignature({ const result = await verifyWalletSignature({