feat: connect wallet favorites to backend

This commit is contained in:
TerryM
2026-06-04 17:06:29 +08:00
parent fd19ed438e
commit 01eab88c0f
14 changed files with 479 additions and 172 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
/**
@@ -23,6 +23,15 @@ export function ScrollToTop() {
const prevPathname = useRef(pathname);
useEffect(() => {
if (!("scrollRestoration" in window.history)) return;
const previous = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
return () => {
window.history.scrollRestoration = previous;
};
}, []);
useLayoutEffect(() => {
const pathnameChanged = prevPathname.current !== pathname;
prevPathname.current = pathname;

View File

@@ -1,17 +1,12 @@
import { apiBase, type Resource } from "../api";
import { apiBase, itemsOrEmpty, type Resource } from "../api";
export type FavoriteSort = "favorited_at" | "published_at" | "hot";
export type FavoriteItem = {
favoritedAt: string;
resource: Resource;
};
export type FavoriteListResponse = {
items: FavoriteItem[];
page: number;
limit: number;
total: number;
items: Resource[];
page?: number;
limit?: number;
total?: number;
};
export type FavoriteIdsResponse = {
@@ -20,16 +15,24 @@ export type FavoriteIdsResponse = {
export type FavoriteMutationResponse = {
ok: boolean;
resourceId: string;
favorited: boolean;
changed?: boolean;
resourceId?: string;
favorited?: boolean;
favoritedAt?: string;
favoriteCount: number;
favoriteCount?: number;
};
function authHeaders(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` };
}
function authJSONHeaders(token: string): HeadersInit {
return {
...authHeaders(token),
"Content-Type": "application/json",
};
}
/** HTTP error that preserves the status code so callers can react to 401s. */
export class FavoriteHttpError extends Error {
readonly status: number;
@@ -68,7 +71,7 @@ export async function listFavorites(
sp.set(key, String(value));
});
const suffix = sp.toString() ? `?${sp}` : "";
const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, {
const res = await fetch(`${apiBase}/api/favorites${suffix}`, {
headers: authHeaders(token),
});
return parseJSON<FavoriteListResponse>(res);
@@ -81,22 +84,23 @@ export async function getFavoriteIds(
if (resourceIds.length === 0) return [];
const uniqueIds = [...new Set(resourceIds)].slice(0, 100);
const res = await fetch(
`${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent(
uniqueIds.join(","),
)}`,
`${apiBase}/api/favorites?ids=${encodeURIComponent(uniqueIds.join(","))}`,
{ headers: authHeaders(token) },
);
const data = await parseJSON<FavoriteIdsResponse>(res);
return data.ids;
const data = await parseJSON<FavoriteIdsResponse | FavoriteListResponse>(res);
if ("ids" in data && Array.isArray(data.ids)) return data.ids;
if ("items" in data) return itemsOrEmpty(data.items).map((item) => item.id);
return [];
}
export async function addFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, {
method: "POST",
headers: authHeaders(token),
headers: authJSONHeaders(token),
body: JSON.stringify({ add: true }),
});
return parseJSON<FavoriteMutationResponse>(res);
}
@@ -105,9 +109,10 @@ export async function removeFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
method: "DELETE",
headers: authHeaders(token),
const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, {
method: "POST",
headers: authJSONHeaders(token),
body: JSON.stringify({ add: false }),
});
return parseJSON<FavoriteMutationResponse>(res);
}

View File

@@ -50,20 +50,12 @@ header button {
}
}
/* Desktop header nav: thin scrollbar when links overflow (still 單列) */
.header-nav-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(238, 183, 38, 0.45) transparent;
-ms-overflow-style: none;
scrollbar-width: none;
}
.header-nav-scroll::-webkit-scrollbar {
height: 4px;
}
.header-nav-scroll::-webkit-scrollbar-thumb {
background-color: rgba(238, 183, 38, 0.45);
border-radius: 9999px;
}
.header-nav-scroll::-webkit-scrollbar-track {
background: transparent;
display: none;
}
.gold-underline {

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";
@@ -690,6 +696,20 @@ export function PublicLayout() {
ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40"
/>
<Link
to={lp("/favorites")}
reloadDocument
onClick={() => window.scrollTo({ top: 0, left: 0 })}
className={`hidden h-10 shrink-0 items-center justify-center gap-2 rounded-full border px-3 text-sm font-bold outline-none transition 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 bg-ark-gold text-black"
: "border-ark-line bg-[#1a1b20] text-neutral-200 hover:border-ark-gold/50 hover:text-ark-gold"
}`}
aria-current={na("favorites") ? "page" : undefined}
>
<Heart className="h-[18px] w-[18px]" strokeWidth={2.2} />
<span className="hidden xl:inline">{t("favorites")}</span>
</Link>
<div className="hidden md:block">
<WalletButton />
</div>

View File

@@ -163,13 +163,12 @@ export default function Favorites() {
page,
limit: pageSize,
includeUnavailable: true,
lang: langQuery(lang),
})
.then((data) => {
if (cancelled) return;
const resources = itemsOrEmpty(data.items).map((item) => item.resource);
const resources = itemsOrEmpty(data.items);
setItems(resources);
setTotal(data.total);
setTotal(data.total ?? resources.length);
resources.forEach((resource) => markFavorite(resource.id, true));
})
.catch((err) => {
@@ -187,7 +186,7 @@ export default function Favorites() {
return () => {
cancelled = true;
};
}, [category, lang, markFavorite, page, query, reloadKey, sort, t, wallet]);
}, [category, markFavorite, page, query, reloadKey, sort, t, wallet]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const hasFilters = Boolean(category || query || sort !== "favorited_at");

View File

@@ -4,7 +4,7 @@ import {
getInjectedWallet,
type WalletKind,
} from "./injected";
import { localWalletToken, useWallet } from "./WalletProvider";
import { useWallet } from "./WalletProvider";
const AUTO_LOGIN_PARAMS = ["autoLogin", "autologin"];
const ETHEREUM_WAIT_MS = 8000;
@@ -52,7 +52,7 @@ function waitForInjected(kind: WalletKind): Promise<boolean> {
}
export function AutoInjectedLogin() {
const { completeLogin, status } = useWallet();
const { loginAddress, status } = useWallet();
useEffect(() => {
if (typeof window === "undefined") return;
@@ -69,7 +69,7 @@ export function AutoInjectedLogin() {
try {
const address = await connectInjectedWallet(kind);
if (cancelled) return;
completeLogin(localWalletToken(address), address);
await loginAddress(address);
} catch (err) {
console.warn("[wallet-autologin] failed", err);
}

View File

@@ -7,7 +7,7 @@ import {
getInjectedWallet,
type WalletKind,
} from "./injected";
import { localWalletToken, useWallet } from "./WalletProvider";
import { useWallet } from "./WalletProvider";
import { WalletBrandIcon } from "./WalletBrandIcon";
const AUTO_LOGIN_PARAM = "autologin";
@@ -51,7 +51,7 @@ function isMobileDevice(): boolean {
export function WalletLoginModal() {
const { t } = useI18n();
const { closeLoginModal, completeLogin, loginModalOpen } = useWallet();
const { closeLoginModal, loginAddress, loginModalOpen } = useWallet();
const [selected, setSelected] = useState<WalletKind | null>(null);
const [mobileDevice, setMobileDevice] = useState(() => isMobileDevice());
const [state, setState] = useState<LoginState>("idle");
@@ -101,7 +101,7 @@ export function WalletLoginModal() {
try {
const address = await connectInjectedWallet(kind);
if (mobileDevice) {
completeLogin(localWalletToken(address), address);
await loginAddress(address);
return;
}
setPendingLogin({ kind, address });
@@ -112,9 +112,16 @@ export function WalletLoginModal() {
}
};
const confirmPendingLogin = () => {
const confirmPendingLogin = async () => {
if (!pendingLogin) return;
completeLogin(localWalletToken(pendingLogin.address), pendingLogin.address);
setState("connecting");
setError("");
try {
await loginAddress(pendingLogin.address);
} catch (err) {
setState("idle");
setError(walletErrorMessage(err, t));
}
};
const cancelPendingLogin = () => {

View File

@@ -9,7 +9,7 @@ import {
} from "react";
import { useToast } from "../components/Toast";
import { useI18n } from "../i18n";
import { fetchWalletMe } from "./api";
import { fetchWalletMe, loginWithWallet } from "./api";
import { signInWithInjectedWallet, type WalletKind } from "./injected";
import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
@@ -22,18 +22,6 @@ function walletErrorMessage(error: unknown, t: Translate): string {
return t(error.message) || t("walletLoginFailed");
}
const localWalletTokenPrefix = "local-wallet:";
export function localWalletToken(wallet: string): string {
return `${localWalletTokenPrefix}${wallet}`;
}
function walletFromLocalToken(token: string): string | null {
return token.startsWith(localWalletTokenPrefix)
? token.slice(localWalletTokenPrefix.length)
: null;
}
type WalletContextValue = {
address: string | null;
token: string | null;
@@ -43,6 +31,7 @@ type WalletContextValue = {
closeLoginModal: () => void;
signInInjected: (kind?: WalletKind) => Promise<void>;
completeLogin: (token: string, wallet: string) => void;
loginAddress: (address: string) => Promise<void>;
logout: () => void;
};
@@ -71,13 +60,6 @@ export function WalletProvider({ children }: { children: ReactNode }) {
return;
}
const localWallet = walletFromLocalToken(token);
if (localWallet) {
setAddress(localWallet);
setStatus("loggedIn");
return;
}
setStatus("loading");
fetchWalletMe(token)
.then((me) => {
@@ -110,6 +92,14 @@ export function WalletProvider({ children }: { children: ReactNode }) {
[showToast, t],
);
const loginAddress = useCallback(
async (walletAddress: string) => {
const res = await loginWithWallet(walletAddress);
completeLogin(res.token, res.wallet);
},
[completeLogin],
);
const signInInjected = useCallback(
async (kind?: WalletKind) => {
try {
@@ -141,12 +131,14 @@ export function WalletProvider({ children }: { children: ReactNode }) {
closeLoginModal: () => setLoginModalOpen(false),
signInInjected,
completeLogin,
loginAddress,
logout,
}),
[
address,
completeLogin,
loginModalOpen,
loginAddress,
logout,
signInInjected,
status,

View File

@@ -1,11 +1,6 @@
import { apiBase, getJSONAuth, postJSON } from "../api";
export type WalletNonceResponse = {
nonce: string;
message: string;
};
export type WalletVerifyResponse = {
export type WalletLoginResponse = {
token: string;
wallet: string;
};
@@ -36,18 +31,8 @@ export type TokenPocketLoginResult =
signature: string;
};
export function requestWalletNonce(
address: string,
): Promise<WalletNonceResponse> {
return postJSON<WalletNonceResponse>("/api/auth/wallet/nonce", { address });
}
export function verifyWalletSignature(params: {
address: string;
message: string;
signature: string;
}): Promise<WalletVerifyResponse> {
return postJSON<WalletVerifyResponse>("/api/auth/wallet/verify", params);
export function loginWithWallet(address: string): Promise<WalletLoginResponse> {
return postJSON<WalletLoginResponse>("/api/auth/wallet/login", { address });
}
export function fetchWalletMe(token: string): Promise<WalletMeResponse> {

View File

@@ -1,4 +1,4 @@
import { requestWalletNonce, verifyWalletSignature } from "./api";
import { loginWithWallet } from "./api";
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
@@ -26,12 +26,6 @@ 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[] = [];
@@ -57,13 +51,6 @@ function normalizeWalletError(error: unknown): 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" })
@@ -106,31 +93,6 @@ async function requestInjectedAddress(
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;
return ethereum
.request<string>({
method: "personal_sign",
params: [address, hexMessage],
})
.catch((retryError: unknown): never => {
throw normalizeWalletError(retryError);
});
}
}
export function getInjectedEthereum(): EthereumProvider | null {
if (typeof window === "undefined") return null;
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
@@ -214,35 +176,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
token: string;
wallet: string;
}> {
console.info("[wallet-login] start injected", { kind });
logWalletProviders();
const ethereum = getInjectedWallet(kind);
if (!ethereum) {
console.warn("[wallet-login] no injected provider found");
throw new Error("No injected wallet found");
}
console.info("[wallet-login] requesting BNB wallet account…");
const address = await requestInjectedAddress(ethereum);
console.info("[wallet-login] account", address);
console.info("[wallet-login] ensuring BNB Chain (0x38)…");
await ensureBnbChain(ethereum);
console.info("[wallet-login] requesting nonce for", address);
const nonce = await requestWalletNonce(address);
console.info("[wallet-login] got nonce, requesting personal_sign…");
const signature = await personalSign({
ethereum,
message: nonce.message,
address,
});
console.info("[wallet-login] signed, verifying with backend…");
const result = await verifyWalletSignature({
address,
message: nonce.message,
signature,
});
console.info("[wallet-login] verified, wallet =", result.wallet);
const address = await connectInjectedWallet(kind);
console.info("[wallet-login] requesting backend login for", address);
const result = await loginWithWallet(address);
console.info("[wallet-login] logged in, wallet =", result.wallet);
return result;
}

View File

@@ -7,7 +7,7 @@ import {
getInjectedWallet,
type WalletKind,
} from "./injected";
import { localWalletToken, useWallet } from "./WalletProvider";
import { useWallet } from "./WalletProvider";
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
export type WalletConnectLoginMode = "deeplink" | "qr";
@@ -96,7 +96,7 @@ function connectorMatchesWallet(
export function useWalletConnectLogin() {
const available = hasWalletConnectProjectId();
const { address: localAddress, completeLogin } = useWallet();
const { address: localAddress, loginAddress } = useWallet();
const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount();
const { connectAsync, connectors } = useConnect();
const { disconnectAsync } = useDisconnect();
@@ -140,12 +140,17 @@ export function useWalletConnectLogin() {
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(localWalletToken(wagmiAddress), wagmiAddress);
console.info("[wallet-login] local wallet session completed", {
address: wagmiAddress,
});
void loginAddress(wagmiAddress)
.then(() => {
console.info("[wallet-login] wallet session completed", {
address: wagmiAddress,
});
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Wallet login failed");
});
}
}, [completeLogin, localAddress, wagmiAddress, wagmiConnected]);
}, [localAddress, loginAddress, wagmiAddress, wagmiConnected]);
const start = useCallback(
async (
@@ -173,7 +178,7 @@ export function useWalletConnectLogin() {
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(localWalletToken(injectedAddress), injectedAddress);
await loginAddress(injectedAddress);
setState("idle");
return;
} catch (err) {
@@ -256,10 +261,17 @@ export function useWalletConnectLogin() {
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(localWalletToken(address), address);
console.info("[wallet-login] local wallet session completed", {
address,
});
void loginAddress(address)
.then(() => {
console.info("[wallet-login] wallet session completed", {
address,
});
})
.catch((err: unknown) => {
setError(
err instanceof Error ? err.message : "Wallet login failed",
);
});
};
const pollId = window.setInterval(() => {
void connector
@@ -293,7 +305,7 @@ export function useWalletConnectLogin() {
cleanupPollingRef.current = null;
}
},
[available, completeLogin, connectAsync, connectors, disconnectAsync],
[available, connectAsync, connectors, disconnectAsync, loginAddress],
);
return {