diff --git a/WALLET_IMPROVEMENTS.md b/WALLET_IMPROVEMENTS.md new file mode 100644 index 0000000..dbfa090 --- /dev/null +++ b/WALLET_IMPROVEMENTS.md @@ -0,0 +1,153 @@ +# 钱包连接改进说明 + +## 改进概览 + +本次改进针对钱包连接功能进行了全面的优化,提升了用户体验和系统稳定性。 + +## 主要改进点 + +### 1. ✅ 网络切换功能增强 + +**改进内容:** +- 添加了 `switchToCorrectNetwork()` 方法,允许用户一键切换到正确的 GenLayer 网络 +- 在 `AccountPanel` 组件中添加了"切换到 GenLayer 网络"按钮 +- 当用户在错误网络时,会显示明确的警告和操作按钮 + +**代码位置:** +- `frontend/lib/genlayer/WalletProvider.tsx` - 新增 `switchToCorrectNetwork` 方法 +- `frontend/components/AccountPanel.tsx` - 添加网络切换按钮和 UI + +**用户体验:** +- 用户不再需要手动在 MetaMask 中切换网络 +- 提供清晰的操作指引和反馈 + +### 2. ✅ 账户切换后的数据自动刷新 + +**改进内容:** +- 当用户切换账户时,自动触发所有相关数据的刷新 +- 使用自定义事件 `walletAccountChanged` 通知所有组件 +- 所有使用钱包数据的 hooks 都会自动响应账户变化 + +**代码位置:** +- `frontend/lib/genlayer/WalletProvider.tsx` - 在 `switchWalletAccount` 中触发事件 +- `frontend/lib/hooks/useFootballBets.ts` - 所有 hooks 监听账户变化事件 + +**影响的 Hooks:** +- `useBets()` - 自动刷新投注列表 +- `usePlayerPoints()` - 自动刷新用户积分 +- `useLeaderboard()` - 自动刷新排行榜 + +### 3. ✅ 连接超时处理 + +**改进内容:** +- 为 `connectMetaMask()` 函数添加了超时机制(默认 30 秒) +- 使用 `Promise.race()` 实现超时控制 +- 超时时会抛出明确的错误信息 + +**代码位置:** +- `frontend/lib/genlayer/client.ts` - `connectMetaMask` 函数 + +**好处:** +- 防止连接过程无限等待 +- 提供更好的错误反馈 + +### 4. ✅ 网络状态变化提示 + +**改进内容:** +- 当网络切换时,显示成功或警告提示 +- 在 Navbar 顶部添加网络警告横幅(当用户在错误网络时) +- 网络切换成功时显示确认消息 + +**代码位置:** +- `frontend/lib/genlayer/WalletProvider.tsx` - `handleChainChanged` 事件处理 +- `frontend/components/Navbar.tsx` - 网络警告横幅 + +**用户体验:** +- 实时反馈网络状态变化 +- 顶部横幅提供持续可见的警告 + +### 5. ✅ 错误处理和用户反馈优化 + +**改进内容:** +- 改进了错误消息的显示方式 +- 区分不同类型的错误(用户拒绝、超时、网络错误等) +- 账户切换成功时显示确认消息 + +**代码位置:** +- `frontend/lib/genlayer/WalletProvider.tsx` - 所有错误处理逻辑 +- `frontend/components/AccountPanel.tsx` - UI 错误显示 + +## 技术细节 + +### 自定义事件系统 + +使用浏览器自定义事件来通知组件账户变化: + +```typescript +window.dispatchEvent(new CustomEvent("walletAccountChanged", { + detail: { address: newAddress } +})); +``` + +组件监听事件并自动刷新数据: + +```typescript +useEffect(() => { + const handleAccountChange = () => { + queryClient.invalidateQueries({ queryKey: ["bets"] }); + }; + window.addEventListener("walletAccountChanged", handleAccountChange); + return () => { + window.removeEventListener("walletAccountChanged", handleAccountChange); + }; +}, [queryClient]); +``` + +### 超时机制实现 + +```typescript +const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Connection timeout. Please try again.")); + }, timeoutMs); +}); + +return Promise.race([connectPromise, timeoutPromise]); +``` + +## 使用示例 + +### 切换网络 + +```typescript +const { switchToCorrectNetwork } = useWallet(); + +// 在按钮点击时调用 +await switchToCorrectNetwork(); +``` + +### 切换账户 + +```typescript +const { switchWalletAccount } = useWallet(); + +// 切换账户,数据会自动刷新 +await switchWalletAccount(); +``` + +## 未来可能的改进 + +1. **重试机制**:连接失败时提供自动重试选项 +2. **连接状态持久化**:记住用户偏好(是否自动连接) +3. **多钱包支持**:支持 WalletConnect 等其他钱包 +4. **连接历史**:记录连接历史,方便快速切换 +5. **网络状态监控**:定期检查网络状态,自动提示切换 + +## 测试建议 + +1. 测试在不同网络间切换 +2. 测试账户切换后的数据刷新 +3. 测试连接超时场景 +4. 测试用户拒绝连接/切换的场景 +5. 测试网络警告横幅的显示和隐藏 + diff --git a/frontend/components/AccountPanel.tsx b/frontend/components/AccountPanel.tsx index d997250..a43cc6c 100644 --- a/frontend/components/AccountPanel.tsx +++ b/frontend/components/AccountPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { User, LogOut, AlertCircle, ExternalLink } from "lucide-react"; +import { User, LogOut, AlertCircle, ExternalLink, Loader2 } from "lucide-react"; import { useWallet } from "@/lib/genlayer/wallet"; import { usePlayerPoints } from "@/lib/hooks/useFootballBets"; import { success, error, userRejected } from "@/lib/utils/toast"; @@ -29,6 +29,7 @@ export function AccountPanel() { connectWallet, disconnectWallet, switchWalletAccount, + switchToCorrectNetwork, } = useWallet(); const { data: points = 0 } = usePlayerPoints(address); @@ -37,6 +38,7 @@ export function AccountPanel() { const [connectionError, setConnectionError] = useState(""); const [isConnecting, setIsConnecting] = useState(false); const [isSwitching, setIsSwitching] = useState(false); + const [isSwitchingNetwork, setIsSwitchingNetwork] = useState(false); const handleConnect = async () => { if (!isMetaMaskInstalled) { @@ -92,6 +94,23 @@ export function AccountPanel() { } }; + const handleSwitchNetwork = async () => { + try { + setIsSwitchingNetwork(true); + setConnectionError(""); + await switchToCorrectNetwork(); + } catch (err: any) { + console.error("Failed to switch network:", err); + + // Don't show error if user cancelled + if (!err.message?.includes("rejected")) { + setConnectionError(err.message || "Failed to switch network"); + } + } finally { + setIsSwitchingNetwork(false); + } + }; + // Not connected state if (!isConnected) { return ( @@ -241,14 +260,33 @@ export function AccountPanel() { {!isOnCorrectNetwork && ( - - - Network Warning - - You're not on the GenLayer network. Please switch networks in - MetaMask or try reconnecting. - - +
+ + + Network Warning + + You're not on the GenLayer network. Please switch to continue using the app. + + + +
)} {connectionError && ( diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 78cb674..fac73e7 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -4,12 +4,16 @@ import { useState, useEffect } from "react"; import { AccountPanel } from "./AccountPanel"; import { CreateBetModal } from "./CreateBetModal"; import { useBets } from "@/lib/hooks/useFootballBets"; +import { useWallet } from "@/lib/genlayer/wallet"; import { Logo, LogoMark } from "./Logo"; +import { Alert, AlertDescription } from "./ui/alert"; +import { AlertCircle } from "lucide-react"; export function Navbar() { const [isScrolled, setIsScrolled] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); const { data: bets } = useBets(); + const { isConnected, isOnCorrectNetwork } = useWallet(); useEffect(() => { const handleScroll = () => { @@ -44,10 +48,27 @@ export function Navbar() { const resolvedBets = bets?.filter(bet => bet.has_resolved).length || 0; return ( -
+ <> + {/* Network Warning Banner */} + {isConnected && !isOnCorrectNetwork && ( +
+
+ + + + You're not on the GenLayer network. Please switch networks to continue. + + +
+
+ )} +
Promise; disconnectWallet: () => void; switchWalletAccount: () => Promise; + switchToCorrectNetwork: () => Promise; } // Create context with undefined default (will error if used outside Provider) @@ -150,6 +152,17 @@ export function WalletProvider({ children }: { children: ReactNode }) { const correctNetwork = parseInt(chainId, 16) === GENLAYER_CHAIN_ID; const accounts = await getAccounts(); + // Show notification when network changes + if (correctNetwork) { + success("Switched to GenLayer network", { + description: "You're now connected to the correct network." + }); + } else { + warning("Network changed", { + description: "Please switch to GenLayer network to continue." + }); + } + setState((prev) => ({ ...prev, chainId, @@ -281,6 +294,18 @@ export function WalletProvider({ children }: { children: ReactNode }) { isOnCorrectNetwork: correctNetwork, }); + // Show success notification + success("Account switched", { + description: `Now using ${newAddress.slice(0, 6)}...${newAddress.slice(-4)}` + }); + + // Trigger a custom event to notify other components to refresh data + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("walletAccountChanged", { + detail: { address: newAddress } + })); + } + return newAddress; } catch (err: any) { console.error("Error switching account:", err); @@ -299,11 +324,68 @@ export function WalletProvider({ children }: { children: ReactNode }) { } }, []); + /** + * Switch to the correct GenLayer network + * This is a convenience function for when user is on wrong network + */ + const switchToCorrectNetwork = useCallback(async () => { + try { + setState((prev) => ({ ...prev, isLoading: true })); + + info("Switching network...", { + description: "Please confirm the network switch in MetaMask." + }); + + await switchToGenLayerNetwork(); + + // Wait a bit for the network to switch + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Verify we're on the correct network + const correctNetwork = await isOnGenLayerNetwork(); + const chainId = await getCurrentChainId(); + const accounts = await getAccounts(); + + setState({ + address: accounts[0] || null, + chainId, + isConnected: accounts.length > 0, + isLoading: false, + isMetaMaskInstalled: true, + isOnCorrectNetwork: correctNetwork, + }); + + if (correctNetwork) { + success("Network switched successfully", { + description: "You're now connected to GenLayer network." + }); + } else { + warning("Network switch may have failed", { + description: "Please check MetaMask and try again." + }); + } + } catch (err: any) { + console.error("Error switching network:", err); + setState((prev) => ({ ...prev, isLoading: false })); + + if (err.message?.includes("rejected")) { + userRejected("Network switch cancelled"); + } else { + error("Failed to switch network", { + description: err.message || "Please try switching manually in MetaMask." + }); + } + + throw err; + } + }, []); + const value: WalletContextValue = { ...state, connectWallet, disconnectWallet, switchWalletAccount, + switchToCorrectNetwork, }; return {children}; diff --git a/frontend/lib/genlayer/client.ts b/frontend/lib/genlayer/client.ts index c2b493f..5e63280 100644 --- a/frontend/lib/genlayer/client.ts +++ b/frontend/lib/genlayer/client.ts @@ -205,28 +205,41 @@ export async function isOnGenLayerNetwork(): Promise { /** * Connect to MetaMask and ensure we're on GenLayer network + * @param timeoutMs - Timeout in milliseconds (default: 30000 = 30 seconds) * @returns The connected address */ -export async function connectMetaMask(): Promise { +export async function connectMetaMask(timeoutMs: number = 30000): Promise { if (!isMetaMaskInstalled()) { throw new Error("MetaMask is not installed"); } - // Request accounts - const accounts = await requestAccounts(); + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Connection timeout. Please try again.")); + }, timeoutMs); + }); - if (!accounts || accounts.length === 0) { - throw new Error("No accounts found"); - } + // Race between the actual connection and timeout + const connectPromise = (async () => { + // Request accounts + const accounts = await requestAccounts(); - // Check and switch to GenLayer network - const onCorrectNetwork = await isOnGenLayerNetwork(); + if (!accounts || accounts.length === 0) { + throw new Error("No accounts found"); + } - if (!onCorrectNetwork) { - await switchToGenLayerNetwork(); - } + // Check and switch to GenLayer network + const onCorrectNetwork = await isOnGenLayerNetwork(); + + if (!onCorrectNetwork) { + await switchToGenLayerNetwork(); + } + + return accounts[0]; + })(); - return accounts[0]; + return Promise.race([connectPromise, timeoutPromise]); } /** diff --git a/frontend/lib/hooks/useFootballBets.ts b/frontend/lib/hooks/useFootballBets.ts index 109b079..8570991 100644 --- a/frontend/lib/hooks/useFootballBets.ts +++ b/frontend/lib/hooks/useFootballBets.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useEffect } from "react"; import FootballBets from "../contracts/FootballBets"; import { getContractAddress, getStudioUrl } from "../genlayer/client"; import { useWallet } from "../genlayer/wallet"; @@ -52,6 +52,23 @@ export function useFootballBetsContract(): FootballBets | null { */ export function useBets() { const contract = useFootballBetsContract(); + const queryClient = useQueryClient(); + + // Listen for wallet account changes and refresh data + useEffect(() => { + const handleAccountChange = () => { + queryClient.invalidateQueries({ queryKey: ["bets"] }); + queryClient.invalidateQueries({ queryKey: ["playerPoints"] }); + queryClient.invalidateQueries({ queryKey: ["leaderboard"] }); + }; + + if (typeof window !== "undefined") { + window.addEventListener("walletAccountChanged", handleAccountChange); + return () => { + window.removeEventListener("walletAccountChanged", handleAccountChange); + }; + } + }, [queryClient]); return useQuery({ queryKey: ["bets"], @@ -74,6 +91,21 @@ export function useBets() { */ export function usePlayerPoints(address: string | null) { const contract = useFootballBetsContract(); + const queryClient = useQueryClient(); + + // Listen for wallet account changes and refresh data + useEffect(() => { + const handleAccountChange = () => { + queryClient.invalidateQueries({ queryKey: ["playerPoints", address] }); + }; + + if (typeof window !== "undefined") { + window.addEventListener("walletAccountChanged", handleAccountChange); + return () => { + window.removeEventListener("walletAccountChanged", handleAccountChange); + }; + } + }, [queryClient, address]); return useQuery({ queryKey: ["playerPoints", address], @@ -96,6 +128,21 @@ export function usePlayerPoints(address: string | null) { */ export function useLeaderboard() { const contract = useFootballBetsContract(); + const queryClient = useQueryClient(); + + // Listen for wallet account changes and refresh data + useEffect(() => { + const handleAccountChange = () => { + queryClient.invalidateQueries({ queryKey: ["leaderboard"] }); + }; + + if (typeof window !== "undefined") { + window.addEventListener("walletAccountChanged", handleAccountChange); + return () => { + window.removeEventListener("walletAccountChanged", handleAccountChange); + }; + } + }, [queryClient]); return useQuery({ queryKey: ["leaderboard"],