diff --git a/frontend/.env.example b/frontend/.env.example index 5b8ef5f..d78d39d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -11,3 +11,7 @@ NEXT_PUBLIC_GENLAYER_SYMBOL=GEN # GenLayer Football Betting Contract Address # This is the address of your deployed FootballBets contract on GenLayer NEXT_PUBLIC_CONTRACT_ADDRESS=your_contract_address + +# WalletConnect Project ID (required for multi-wallet support) +# Get your free project ID at https://cloud.walletconnect.com +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id diff --git a/frontend/README.md b/frontend/README.md index ec11852..50e1624 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -23,7 +23,8 @@ cp .env.example .env 3. Configure environment variables: - `NEXT_PUBLIC_CONTRACT_ADDRESS` - GenLayer Football Betting contract address - - `NEXT_PUBLIC_STUDIO_URL` - GenLayer Studio URL (default: https://studio.genlayer.com/api) + - `NEXT_PUBLIC_GENLAYER_RPC_URL` - GenLayer RPC URL (default: https://studio.genlayer.com/api) + - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` - WalletConnect Project ID (get free at https://cloud.walletconnect.com) ## Development @@ -55,23 +56,40 @@ npm start ## Tech Stack -- **Next.js 15** - React framework with App Router +- **Next.js 16** - React framework with App Router +- **React 19** - Latest React with concurrent features - **TypeScript** - Type safety - **Tailwind CSS v4** - Styling with custom glass-morphism theme +- **RainbowKit** - Multi-wallet connection UI +- **wagmi** - React hooks for Ethereum +- **viem** - TypeScript Ethereum library - **genlayer-js** - GenLayer blockchain SDK - **TanStack Query (React Query)** - Data fetching and caching - **Radix UI** - Accessible component primitives -- **shadcn/ui** - Pre-built UI components -## Wallet Management +## Wallet Connection -The app uses GenLayer's account system: -- **Create Account**: Generate a new private key -- **Import Account**: Import existing private key -- **Export Account**: Export your private key (secured) -- **Disconnect**: Clear stored account data +The app uses **RainbowKit** for multi-wallet support: -Accounts are stored in browser's localStorage for development convenience. +### Supported Wallets +- **MetaMask** - Browser extension & mobile +- **WalletConnect** - Connect any WalletConnect-compatible wallet +- **Coinbase Wallet** - Coinbase's self-custody wallet +- **Rainbow** - Mobile-first Ethereum wallet +- **And many more** - RainbowKit supports 100+ wallets + +### Getting WalletConnect Project ID +1. Go to [WalletConnect Cloud](https://cloud.walletconnect.com) +2. Create a free account +3. Create a new project +4. Copy the Project ID +5. Add to your `.env` file as `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` + +### Features +- **One-click connection** - Simple modal to connect any supported wallet +- **Network switching** - Automatic prompt to switch to GenLayer network +- **Account management** - View balance, switch accounts, disconnect +- **Persistent sessions** - Stay connected across page refreshes ## Features @@ -81,4 +99,4 @@ Accounts are stored in browser's localStorage for development convenience. - **Leaderboard**: Track top players by points earned from correct predictions - **Player Stats**: View your points and ranking in the community - **Glass-morphism UI**: Premium dark theme with OKLCH colors, backdrop blur effects, and smooth animations -- **Real-time Updates**: Automatic data fetching with 3-second polling intervals via TanStack Query +- **Real-time Updates**: Automatic data fetching with TanStack Query diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index d5e9059..263f6bd 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -2,12 +2,16 @@ import { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider } from "wagmi"; +import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit"; import { Toaster } from "sonner"; +import { wagmiConfig } from "@/lib/wagmi/config"; import { WalletProvider } from "@/lib/genlayer/WalletProvider"; +import "@rainbow-me/rainbowkit/styles.css"; + export function Providers({ children }: { children: React.ReactNode }) { // Use useState to ensure QueryClient is only created once per component lifecycle - // This prevents the client from being recreated on every render const [queryClient] = useState( () => new QueryClient({ @@ -21,25 +25,39 @@ export function Providers({ children }: { children: React.ReactNode }) { ); return ( - - - {children} - - - + + + + + {children} + + + + + ); } diff --git a/frontend/components/AccountPanel.tsx b/frontend/components/AccountPanel.tsx index d997250..a31311d 100644 --- a/frontend/components/AccountPanel.tsx +++ b/frontend/components/AccountPanel.tsx @@ -1,295 +1,137 @@ "use client"; -import { useState } from "react"; -import { User, LogOut, AlertCircle, ExternalLink } from "lucide-react"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; import { useWallet } from "@/lib/genlayer/wallet"; import { usePlayerPoints } from "@/lib/hooks/useFootballBets"; -import { success, error, userRejected } from "@/lib/utils/toast"; -import { AddressDisplay } from "./AddressDisplay"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "./ui/dialog"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; - -const METAMASK_INSTALL_URL = "https://metamask.io/download/"; export function AccountPanel() { - const { - address, - isConnected, - isMetaMaskInstalled, - isOnCorrectNetwork, - isLoading, - connectWallet, - disconnectWallet, - switchWalletAccount, - } = useWallet(); - + const { address } = useWallet(); const { data: points = 0 } = usePlayerPoints(address); - const [isModalOpen, setIsModalOpen] = useState(false); - const [connectionError, setConnectionError] = useState(""); - const [isConnecting, setIsConnecting] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); - - const handleConnect = async () => { - if (!isMetaMaskInstalled) { - return; - } - - try { - setIsConnecting(true); - setConnectionError(""); - await connectWallet(); - setIsModalOpen(false); - } catch (err: any) { - console.error("Failed to connect wallet:", err); - setConnectionError(err.message || "Failed to connect to MetaMask"); - - if (err.message?.includes("rejected")) { - userRejected("Connection cancelled"); - } else { - error("Failed to connect wallet", { - description: err.message || "Check your MetaMask and try again." - }); - } - } finally { - setIsConnecting(false); - } - }; - - const handleDisconnect = () => { - disconnectWallet(); - setIsModalOpen(false); - }; - - const handleSwitchAccount = async () => { - try { - setIsSwitching(true); - setConnectionError(""); - await switchWalletAccount(); - // Keep modal open to show new account info - } catch (err: any) { - console.error("Failed to switch account:", err); - - // Don't show error if user cancelled - if (!err.message?.includes("rejected")) { - setConnectionError(err.message || "Failed to switch account"); - error("Failed to switch account", { - description: err.message || "Please try again." - }); - } else { - userRejected("Account switch cancelled"); - } - } finally { - setIsSwitching(false); - } - }; - - // Not connected state - if (!isConnected) { - return ( - - - - - - - - Connect to GenLayer - - - Connect your MetaMask wallet to start betting - - - -
- {!isMetaMaskInstalled ? ( - <> - - - MetaMask Not Detected - - Please install MetaMask to continue. MetaMask is a crypto - wallet that allows you to interact with blockchain applications. - - - - - -
-

- After installing MetaMask, refresh this page and click - "Connect Wallet" again. -

-
- - ) : ( - <> - - - {connectionError && ( - - - Connection Error - {connectionError} - - )} - -
-

- This will open MetaMask and prompt you to: -

-
    -
  1. Connect your wallet to this application
  2. -
  3. Add the GenLayer network to MetaMask
  4. -
  5. Switch to the GenLayer network
  6. -
-
- - )} -
-
-
- ); - } - - // Connected state return ( - -
-
-
- - -
-
-
- {points} - pts -
+
+ {/* Show points when connected */} + {address && ( +
+ {points} + pts
- - - - -
- - - - - Wallet Details - - - Your connected MetaMask wallet information - - - -
-
-

Your Address

- {address} -
- -
-

Your Points

-

{points}

-
- -
-

Network Status

-
-
- - {isOnCorrectNetwork - ? "Connected to GenLayer" - : "Wrong Network"} - -
-
- - {!isOnCorrectNetwork && ( - - - Network Warning - - You're not on the GenLayer network. Please switch networks in - MetaMask or try reconnecting. - - - )} - - {connectionError && ( - - - Error - {connectionError} - - )} - -
- - - -
- -
-

- Use "Switch Account" to select a different MetaMask - account. Use "Disconnect" to remove this site from - MetaMask. -

-
-
- -
+ {(() => { + if (!connected) { + return ( + + ); + } + + if (chain.unsupported) { + return ( + + ); + } + + return ( +
+ + + +
+ ); + })()} + + ); + }} + + ); } diff --git a/frontend/components/BetsTable.tsx b/frontend/components/BetsTable.tsx index d588f12..068297c 100644 --- a/frontend/components/BetsTable.tsx +++ b/frontend/components/BetsTable.tsx @@ -3,7 +3,6 @@ import { Loader2, Trophy, Clock, AlertCircle } from "lucide-react"; import { useBets, useResolveBet, useFootballBetsContract } from "@/lib/hooks/useFootballBets"; import { useWallet } from "@/lib/genlayer/wallet"; -import { error } from "@/lib/utils/toast"; import { AddressDisplay } from "./AddressDisplay"; import { Button } from "./ui/button"; import { Badge } from "./ui/badge"; @@ -12,16 +11,15 @@ import type { Bet } from "@/lib/contracts/types"; export function BetsTable() { const contract = useFootballBetsContract(); const { data: bets, isLoading, isError } = useBets(); - const { address, isConnected, isLoading: isWalletLoading } = useWallet(); + const { address, isConnected, isLoading: isWalletLoading, connectWallet } = useWallet(); const { resolveBet, isResolving, resolvingBetId } = useResolveBet(); const handleResolve = (betId: string) => { if (!address) { - error("Please connect your wallet to resolve bets"); + connectWallet(); return; } - // Confirmation popup const confirmed = confirm("Are you sure you want to resolve this bet? This action will determine the winner."); if (confirmed) { diff --git a/frontend/components/CreateBetModal.tsx b/frontend/components/CreateBetModal.tsx index dd7e337..2013ca4 100644 --- a/frontend/components/CreateBetModal.tsx +++ b/frontend/components/CreateBetModal.tsx @@ -4,14 +4,14 @@ import { useState, useEffect } from "react"; import { Plus, Loader2, Calendar, Users } from "lucide-react"; import { useCreateBet } from "@/lib/hooks/useFootballBets"; import { useWallet } from "@/lib/genlayer/wallet"; -import { success, error } from "@/lib/utils/toast"; +import { error } from "@/lib/utils/toast"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; export function CreateBetModal() { - const { isConnected, address, isLoading } = useWallet(); + const { isConnected, address, isLoading, connectWallet } = useWallet(); const { createBet, isCreating, isSuccess } = useCreateBet(); const [isOpen, setIsOpen] = useState(false); @@ -67,7 +67,7 @@ export function CreateBetModal() { e.preventDefault(); if (!isConnected || !address) { - error("Please connect your wallet first"); + connectWallet(); return; } @@ -79,7 +79,7 @@ export function CreateBetModal() { gameDate, team1, team2, - predictedWinner: predictedWinner, // Send "1", "2", or "0" directly + predictedWinner: predictedWinner, }); }; diff --git a/frontend/lib/genlayer/WalletProvider.tsx b/frontend/lib/genlayer/WalletProvider.tsx index 44dae0a..919de0b 100644 --- a/frontend/lib/genlayer/WalletProvider.tsx +++ b/frontend/lib/genlayer/WalletProvider.tsx @@ -1,312 +1,70 @@ "use client"; -import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; -import { - isMetaMaskInstalled, - connectMetaMask, - switchAccount, - getAccounts, - getCurrentChainId, - isOnGenLayerNetwork, - getEthereumProvider, - GENLAYER_CHAIN_ID, -} from "./client"; -import { error, userRejected, warning } from "../utils/toast"; - -// localStorage key for tracking user's disconnect intent -const DISCONNECT_FLAG = "wallet_disconnected"; +import React, { createContext, useContext, ReactNode } from "react"; +import { useAccount, useChainId, useDisconnect } from "wagmi"; +import { useConnectModal, useAccountModal } from "@rainbow-me/rainbowkit"; +import { GENLAYER_CHAIN_ID } from "@/lib/wagmi/config"; export interface WalletState { address: string | null; - chainId: string | null; + chainId: number | null; isConnected: boolean; isLoading: boolean; - isMetaMaskInstalled: boolean; isOnCorrectNetwork: boolean; } interface WalletContextValue extends WalletState { - connectWallet: () => Promise; + connectWallet: () => void; disconnectWallet: () => void; - switchWalletAccount: () => Promise; + openAccountModal: () => void; } -// Create context with undefined default (will error if used outside Provider) const WalletContext = createContext(undefined); /** - * WalletProvider component that manages wallet state and provides it to all children - * This ensures all components share the same wallet state and react to changes + * WalletProvider component that wraps wagmi/RainbowKit hooks + * Provides a simplified interface for wallet interactions */ export function WalletProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - address: null, - chainId: null, - isConnected: false, - isLoading: true, - isMetaMaskInstalled: false, - isOnCorrectNetwork: false, - }); - - // Check MetaMask installation and load account on mount - useEffect(() => { - const initWallet = async () => { - const installed = isMetaMaskInstalled(); - - if (!installed) { - setState({ - address: null, - chainId: null, - isConnected: false, - isLoading: false, - isMetaMaskInstalled: false, - isOnCorrectNetwork: false, - }); - return; - } - - // Check if user intentionally disconnected - // If they did, don't auto-reconnect even if MetaMask has permissions - if (typeof window !== "undefined") { - const wasDisconnected = - localStorage.getItem(DISCONNECT_FLAG) === "true"; - - if (wasDisconnected) { - // User explicitly disconnected, don't auto-reconnect - setState({ - address: null, - chainId: null, - isConnected: false, - isLoading: false, - isMetaMaskInstalled: true, - isOnCorrectNetwork: false, - }); - return; - } - } - - try { - // Get current accounts (without requesting) - // This will auto-reconnect if MetaMask has existing permissions - // and user didn't explicitly disconnect - const accounts = await getAccounts(); - const chainId = await getCurrentChainId(); - const correctNetwork = await isOnGenLayerNetwork(); - - setState({ - address: accounts[0] || null, - chainId, - isConnected: accounts.length > 0, - isLoading: false, - isMetaMaskInstalled: true, - isOnCorrectNetwork: correctNetwork, - }); - } catch (error) { - console.error("Error initializing wallet:", error); - setState({ - address: null, - chainId: null, - isConnected: false, - isLoading: false, - isMetaMaskInstalled: true, - isOnCorrectNetwork: false, - }); - } - }; - - initWallet(); - }, []); - - // Set up MetaMask event listeners (ONCE for entire app) - useEffect(() => { - const provider = getEthereumProvider(); - - if (!provider) { - return; - } - - const handleAccountsChanged = async (accounts: string[]) => { - const chainId = await getCurrentChainId(); - const correctNetwork = await isOnGenLayerNetwork(); - - // If user connected via MetaMask UI, clear the disconnect flag - // This allows future auto-reconnects - if (accounts.length > 0 && typeof window !== "undefined") { - localStorage.removeItem(DISCONNECT_FLAG); - } - - setState((prev) => ({ - ...prev, - address: accounts[0] || null, - chainId, - isConnected: accounts.length > 0, - isOnCorrectNetwork: correctNetwork, - })); - }; - - const handleChainChanged = async (chainId: string) => { - // MetaMask recommends reloading the page on chain change - // but we'll update state instead for better UX - const correctNetwork = parseInt(chainId, 16) === GENLAYER_CHAIN_ID; - const accounts = await getAccounts(); - - setState((prev) => ({ - ...prev, - chainId, - address: accounts[0] || null, - isConnected: accounts.length > 0, - isOnCorrectNetwork: correctNetwork, - })); - }; - - const handleDisconnect = () => { - setState((prev) => ({ - ...prev, - address: null, - isConnected: false, - })); - }; - - // Add event listeners - provider.on("accountsChanged", handleAccountsChanged); - provider.on("chainChanged", handleChainChanged); - provider.on("disconnect", handleDisconnect); - - // Cleanup - return () => { - provider.removeListener("accountsChanged", handleAccountsChanged); - provider.removeListener("chainChanged", handleChainChanged); - provider.removeListener("disconnect", handleDisconnect); - }; - }, []); - - /** - * Connect to MetaMask - */ - const connectWallet = useCallback(async () => { - try { - setState((prev) => ({ ...prev, isLoading: true })); - - const address = await connectMetaMask(); - const chainId = await getCurrentChainId(); - const correctNetwork = await isOnGenLayerNetwork(); - - // User is connecting, clear the disconnect flag - // This allows auto-reconnect on future page loads - if (typeof window !== "undefined") { - localStorage.removeItem(DISCONNECT_FLAG); - } - - setState({ - address, - chainId, - isConnected: true, - isLoading: false, - isMetaMaskInstalled: true, - isOnCorrectNetwork: correctNetwork, - }); - - return address; - } catch (err: any) { - console.error("Error connecting wallet:", err); - setState((prev) => ({ ...prev, isLoading: false })); - - // Handle specific error types with appropriate toasts - if (err.message?.includes("rejected")) { - userRejected("Connection cancelled"); - } else if (err.message?.includes("MetaMask is not installed")) { - error("MetaMask not found", { - description: "Please install MetaMask to connect your wallet.", - action: { - label: "Install MetaMask", - onClick: () => window.open("https://metamask.io/download/", "_blank") - } - }); - } else { - error("Failed to connect wallet", { - description: err.message || "Please check your MetaMask and try again." - }); - } - - throw err; + const { address, isConnected, isConnecting, isReconnecting } = useAccount(); + const chainId = useChainId(); + const { disconnect } = useDisconnect(); + const { openConnectModal } = useConnectModal(); + const { openAccountModal } = useAccountModal(); + + const isLoading = isConnecting || isReconnecting; + const isOnCorrectNetwork = chainId === GENLAYER_CHAIN_ID; + + const connectWallet = () => { + if (openConnectModal) { + openConnectModal(); } - }, []); - - /** - * Disconnect wallet (clear local state and persist disconnect intent) - * Sets a flag in localStorage to prevent auto-reconnect on page refresh - */ - const disconnectWallet = useCallback(() => { - // Persist user's intent to disconnect - // This prevents auto-reconnect on page refresh - if (typeof window !== "undefined") { - localStorage.setItem(DISCONNECT_FLAG, "true"); - } - - setState((prev) => ({ - ...prev, - address: null, - isConnected: false, - })); - }, []); - - /** - * Request user to switch to different MetaMask account - * Shows MetaMask account picker even if already connected - */ - const switchWalletAccount = useCallback(async () => { - try { - setState((prev) => ({ ...prev, isLoading: true })); - - // Request account switch via MetaMask picker - const newAddress = await switchAccount(); - - // Get updated state - const chainId = await getCurrentChainId(); - const correctNetwork = await isOnGenLayerNetwork(); - - // Clear disconnect flag - user is actively connecting - if (typeof window !== "undefined") { - localStorage.removeItem(DISCONNECT_FLAG); - } - - // Update state immediately for better UX - // accountsChanged event will also fire, but that's okay - setState({ - address: newAddress, - chainId, - isConnected: true, - isLoading: false, - isMetaMaskInstalled: true, - isOnCorrectNetwork: correctNetwork, - }); - - return newAddress; - } catch (err: any) { - console.error("Error switching account:", err); - setState((prev) => ({ ...prev, isLoading: false })); + }; - // Handle specific error types - if (err.message?.includes("rejected")) { - userRejected("Account switch cancelled"); - } else { - error("Failed to switch account", { - description: err.message || "Please try again." - }); - } + const disconnectWallet = () => { + disconnect(); + }; - throw err; + const handleOpenAccountModal = () => { + if (openAccountModal) { + openAccountModal(); } - }, []); + }; const value: WalletContextValue = { - ...state, + address: address ?? null, + chainId: chainId ?? null, + isConnected, + isLoading, + isOnCorrectNetwork, connectWallet, disconnectWallet, - switchWalletAccount, + openAccountModal: handleOpenAccountModal, }; - return {children}; + return ( + {children} + ); } /** diff --git a/frontend/lib/genlayer/client.ts b/frontend/lib/genlayer/client.ts index c2b493f..37fb300 100644 --- a/frontend/lib/genlayer/client.ts +++ b/frontend/lib/genlayer/client.ts @@ -2,37 +2,9 @@ import { createClient } from "genlayer-js"; import { studionet } from "genlayer-js/chains"; -import { createWalletClient, custom, type WalletClient } from "viem"; -// GenLayer Network Configuration (from environment variables with fallbacks) -export const GENLAYER_CHAIN_ID = parseInt(process.env.NEXT_PUBLIC_GENLAYER_CHAIN_ID || "61999"); -export const GENLAYER_CHAIN_ID_HEX = `0x${GENLAYER_CHAIN_ID.toString(16).toUpperCase()}`; - -export const GENLAYER_NETWORK = { - chainId: GENLAYER_CHAIN_ID_HEX, - chainName: process.env.NEXT_PUBLIC_GENLAYER_CHAIN_NAME || "GenLayer Studio", - nativeCurrency: { - name: process.env.NEXT_PUBLIC_GENLAYER_SYMBOL || "GEN", - symbol: process.env.NEXT_PUBLIC_GENLAYER_SYMBOL || "GEN", - decimals: 18, - }, - rpcUrls: [process.env.NEXT_PUBLIC_GENLAYER_RPC_URL || "https://studio.genlayer.com/api"], - blockExplorerUrls: [], -}; - -// Ethereum provider type from window -interface EthereumProvider { - isMetaMask?: boolean; - request: (args: { method: string; params?: any[] }) => Promise; - on: (event: string, handler: (...args: any[]) => void) => void; - removeListener: (event: string, handler: (...args: any[]) => void) => void; -} - -declare global { - interface Window { - ethereum?: EthereumProvider; - } -} +// Re-export chain ID from wagmi config +export { GENLAYER_CHAIN_ID } from "@/lib/wagmi/config"; /** * Get the GenLayer RPC URL from environment variables @@ -49,253 +21,13 @@ export function getStudioUrl(): string { export function getContractAddress(): string { const address = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS; if (!address) { - // Return empty string during build, error will be shown in UI during runtime return ""; } return address; } /** - * Check if MetaMask is installed - */ -export function isMetaMaskInstalled(): boolean { - if (typeof window === "undefined") return false; - return !!window.ethereum?.isMetaMask; -} - -/** - * Get the Ethereum provider (MetaMask) - */ -export function getEthereumProvider(): EthereumProvider | null { - if (typeof window === "undefined") return null; - return window.ethereum || null; -} - -/** - * Request accounts from MetaMask - * @returns Array of addresses - */ -export async function requestAccounts(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - throw new Error("MetaMask is not installed"); - } - - try { - const accounts = await provider.request({ - method: "eth_requestAccounts", - }); - return accounts; - } catch (error: any) { - if (error.code === 4001) { - throw new Error("User rejected the connection request"); - } - throw new Error(`Failed to connect to MetaMask: ${error.message}`); - } -} - -/** - * Get current MetaMask accounts without requesting permission - * @returns Array of addresses - */ -export async function getAccounts(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - return []; - } - - try { - const accounts = await provider.request({ - method: "eth_accounts", - }); - return accounts; - } catch (error) { - console.error("Error getting accounts:", error); - return []; - } -} - -/** - * Get the current chain ID from MetaMask - */ -export async function getCurrentChainId(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - return null; - } - - try { - const chainId = await provider.request({ - method: "eth_chainId", - }); - return chainId; - } catch (error) { - console.error("Error getting chain ID:", error); - return null; - } -} - -/** - * Add GenLayer network to MetaMask - */ -export async function addGenLayerNetwork(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - throw new Error("MetaMask is not installed"); - } - - try { - await provider.request({ - method: "wallet_addEthereumChain", - params: [GENLAYER_NETWORK], - }); - } catch (error: any) { - if (error.code === 4001) { - throw new Error("User rejected adding the network"); - } - throw new Error(`Failed to add GenLayer network: ${error.message}`); - } -} - -/** - * Switch to GenLayer network - */ -export async function switchToGenLayerNetwork(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - throw new Error("MetaMask is not installed"); - } - - try { - await provider.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: GENLAYER_CHAIN_ID_HEX }], - }); - } catch (error: any) { - // If the chain is not added, add it - if (error.code === 4902) { - await addGenLayerNetwork(); - } else if (error.code === 4001) { - throw new Error("User rejected switching the network"); - } else { - throw new Error(`Failed to switch network: ${error.message}`); - } - } -} - -/** - * Check if we're on the GenLayer network - */ -export async function isOnGenLayerNetwork(): Promise { - const chainId = await getCurrentChainId(); - - if (!chainId) { - return false; - } - - // Convert both to decimal for comparison - const currentChainIdDecimal = parseInt(chainId, 16); - return currentChainIdDecimal === GENLAYER_CHAIN_ID; -} - -/** - * Connect to MetaMask and ensure we're on GenLayer network - * @returns The connected address - */ -export async function connectMetaMask(): Promise { - if (!isMetaMaskInstalled()) { - throw new Error("MetaMask is not installed"); - } - - // Request accounts - const accounts = await requestAccounts(); - - if (!accounts || accounts.length === 0) { - throw new Error("No accounts found"); - } - - // Check and switch to GenLayer network - const onCorrectNetwork = await isOnGenLayerNetwork(); - - if (!onCorrectNetwork) { - await switchToGenLayerNetwork(); - } - - return accounts[0]; -} - -/** - * Request user to switch MetaMask account - * Shows MetaMask account picker even if already connected - * Uses wallet_requestPermissions to force account selection dialog - * @returns The newly selected account address - */ -export async function switchAccount(): Promise { - const provider = getEthereumProvider(); - - if (!provider) { - throw new Error("MetaMask is not installed"); - } - - try { - // Request permissions - this shows account picker - await provider.request({ - method: "wallet_requestPermissions", - params: [{ eth_accounts: {} }], - }); - - // Get the newly selected account - const accounts = await provider.request({ - method: "eth_accounts", - }); - - if (!accounts || accounts.length === 0) { - throw new Error("No account selected"); - } - - return accounts[0]; - } catch (error: any) { - if (error.code === 4001) { - throw new Error("User rejected account switch"); - } else if (error.code === -32002) { - throw new Error("Account switch request already pending"); - } - throw new Error(`Failed to switch account: ${error.message}`); - } -} - -/** - * Create a viem wallet client from MetaMask provider - */ -export function createMetaMaskWalletClient(): WalletClient | null { - const provider = getEthereumProvider(); - - if (!provider) { - return null; - } - - try { - return createWalletClient({ - chain: studionet as any, - transport: custom(provider), - }); - } catch (error) { - console.error("Error creating wallet client:", error); - return null; - } -} - -/** - * Create a GenLayer client with MetaMask account - * - * Note: The genlayer-js SDK doesn't directly support custom transports like viem. - * When an address is provided, the SDK will use the window.ethereum provider - * automatically for transaction signing via MetaMask. + * Create a GenLayer client with optional account */ export function createGenLayerClient(address?: string) { const config: any = { @@ -310,18 +42,8 @@ export function createGenLayerClient(address?: string) { return createClient(config); } catch (error) { console.error("Error creating GenLayer client:", error); - // Return client without account on error return createClient({ chain: studionet, }); } } - -/** - * Get a client instance with MetaMask account - */ -export async function getClient() { - const accounts = await getAccounts(); - const address = accounts[0]; - return createGenLayerClient(address); -} diff --git a/frontend/lib/genlayer/wallet.ts b/frontend/lib/genlayer/wallet.ts index 306a3d7..1b8a553 100644 --- a/frontend/lib/genlayer/wallet.ts +++ b/frontend/lib/genlayer/wallet.ts @@ -2,8 +2,6 @@ /** * Re-export wallet functionality from WalletProvider - * This maintains backward compatibility with existing imports - * All components that import from this file will now use shared context state */ export { useWallet, WalletProvider } from "./WalletProvider"; export type { WalletState } from "./WalletProvider"; diff --git a/frontend/lib/hooks/useFootballBets.ts b/frontend/lib/hooks/useFootballBets.ts index 109b079..ad3cc23 100644 --- a/frontend/lib/hooks/useFootballBets.ts +++ b/frontend/lib/hooks/useFootballBets.ts @@ -5,7 +5,7 @@ import { useMemo, useState } from "react"; import FootballBets from "../contracts/FootballBets"; import { getContractAddress, getStudioUrl } from "../genlayer/client"; import { useWallet } from "../genlayer/wallet"; -import { success, error, configError } from "../utils/toast"; +import { success, error } from "../utils/toast"; import type { Bet, LeaderboardEntry } from "../contracts/types"; /** @@ -13,9 +13,6 @@ import type { Bet, LeaderboardEntry } from "../contracts/types"; * * Returns null if contract address is not configured. * The contract instance is recreated whenever the wallet address changes. - * Read-only operations (getBets, getLeaderboard, etc.) work without a connected wallet. - * Write operations (createBet, resolveBet) require a connected wallet and will fail - * if the address is null. Defensive validation is added in the mutation hooks. */ export function useFootballBetsContract(): FootballBets | null { const { address } = useWallet(); @@ -23,22 +20,10 @@ export function useFootballBetsContract(): FootballBets | null { const studioUrl = getStudioUrl(); const contract = useMemo(() => { - // Validate contract address is configured if (!contractAddress) { - configError( - "Setup Required", - "Contract address not configured. Please set NEXT_PUBLIC_CONTRACT_ADDRESS in your .env file.", - { - label: "Setup Guide", - onClick: () => window.open("/docs/setup", "_blank") - } - ); - // Return null to indicate contract is not available return null; } - // Contract instance is recreated when address changes to ensure - // the genlayer-js client is properly configured with the current account return new FootballBets(contractAddress, address, studioUrl); }, [contractAddress, address, studioUrl]); diff --git a/frontend/lib/utils/toast.ts b/frontend/lib/utils/toast.ts index 902ad4a..50a44b0 100644 --- a/frontend/lib/utils/toast.ts +++ b/frontend/lib/utils/toast.ts @@ -81,21 +81,17 @@ export const loading = (message: string, options?: ExternalToast) => { // Promise toast for handling async operations export const promise = ( - promise: Promise, + promiseArg: Promise, messages: { loading: string; success: string | ((result: T) => string); error: string | ((error: any) => string); - }, - options?: ExternalToast + } ) => { - return sonnerToast.promise(promise, { + return sonnerToast.promise(promiseArg, { loading: messages.loading, success: messages.success, error: messages.error, - }, { - ...defaultOptions, - ...options, }); }; diff --git a/frontend/lib/wagmi/config.ts b/frontend/lib/wagmi/config.ts new file mode 100644 index 0000000..a3e910f --- /dev/null +++ b/frontend/lib/wagmi/config.ts @@ -0,0 +1,46 @@ +"use client"; + +import { getDefaultConfig, type Chain as RainbowKitChain } from "@rainbow-me/rainbowkit"; +import { http } from "wagmi"; + +// GenLayer Network Configuration (from environment variables with fallbacks) +const GENLAYER_CHAIN_ID = parseInt(process.env.NEXT_PUBLIC_GENLAYER_CHAIN_ID || "61999"); +const GENLAYER_RPC_URL = process.env.NEXT_PUBLIC_GENLAYER_RPC_URL || "https://studio.genlayer.com/api"; +const GENLAYER_CHAIN_NAME = process.env.NEXT_PUBLIC_GENLAYER_CHAIN_NAME || "GenLayer Studio"; +const GENLAYER_SYMBOL = process.env.NEXT_PUBLIC_GENLAYER_SYMBOL || "GEN"; + +// Define GenLayer as a custom chain for RainbowKit +export const genlayerChain = { + id: GENLAYER_CHAIN_ID, + name: GENLAYER_CHAIN_NAME, + nativeCurrency: { + name: GENLAYER_SYMBOL, + symbol: GENLAYER_SYMBOL, + decimals: 18, + }, + rpcUrls: { + default: { + http: [GENLAYER_RPC_URL], + }, + }, + iconUrl: "/genlayer-icon.svg", + iconBackground: "#9B6AF6", + testnet: true, +} as const satisfies RainbowKitChain; + +// RainbowKit configuration with GenLayer chain +// Note: Get your projectId from https://cloud.walletconnect.com (free) +const WALLETCONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "demo-project-id"; + +export const wagmiConfig = getDefaultConfig({ + appName: "GenLayer Football Betting", + projectId: WALLETCONNECT_PROJECT_ID, + chains: [genlayerChain], + transports: { + [GENLAYER_CHAIN_ID]: http(GENLAYER_RPC_URL), + }, + ssr: true, +}); + +// Export chain ID for use in other parts of the app +export { GENLAYER_CHAIN_ID, GENLAYER_RPC_URL }; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 4b2803a..e0dd5eb 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,7 +2,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, - turbopack: {}, + // Disable Turbopack for build due to compatibility issues with some dependencies + // Use webpack for production builds + webpack: (config) => { + config.resolve.fallback = { fs: false, net: false, tls: false }; + config.externals.push("pino-pretty", "lokijs", "encoding"); + return config; + }, }; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index fb7993c..b32c143 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,8 @@ "description": "GenLayer Football Betting - AI-powered prediction betting on GenLayer blockchain.", "main": "index.js", "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --turbopack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, @@ -14,6 +14,7 @@ "license": "ISC", "type": "module", "dependencies": { + "@rainbow-me/rainbowkit": "^2.2.4", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/frontend/public/genlayer-icon.svg b/frontend/public/genlayer-icon.svg new file mode 100644 index 0000000..bcbd700 --- /dev/null +++ b/frontend/public/genlayer-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 669c18e..1e1fd66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", + "@rainbow-me/rainbowkit": "^2.2.4", "@tanstack/react-query": "^5.90.5", "@wagmi/connectors": "^6.1.3", "@wagmi/core": "^2.22.1", @@ -479,6 +480,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -2457,6 +2464,56 @@ } } }, + "node_modules/@rainbow-me/rainbowkit": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@rainbow-me/rainbowkit/-/rainbowkit-2.2.10.tgz", + "integrity": "sha512-8+E4die1A2ovN9t3lWxWnwqTGEdFqThXDQRj+E4eDKuUKyymYD+66Gzm6S9yfg8E95c6hmGlavGUfYPtl1EagA==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/css": "1.17.3", + "@vanilla-extract/dynamic": "2.1.4", + "@vanilla-extract/sprinkles": "1.6.4", + "clsx": "2.1.1", + "cuer": "0.0.3", + "react-remove-scroll": "2.6.2", + "ua-parser-js": "^1.0.37" + }, + "engines": { + "node": ">=12.4" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "react-dom": ">=18", + "viem": "2.x", + "wagmi": "^2.9.0" + } + }, + "node_modules/@rainbow-me/rainbowkit/node_modules/react-remove-scroll": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", + "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@reown/appkit": { "version": "1.7.8", "resolved": "https://registry.npmjs.org/@reown/appkit/-/appkit-1.7.8.tgz", @@ -4795,6 +4852,50 @@ "@types/node": "*" } }, + "node_modules/@vanilla-extract/css": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.17.3.tgz", + "integrity": "sha512-jHivr1UPoJTX5Uel4AZSOwrCf4mO42LcdmnhJtUxZaRWhW4FviFbIfs0moAWWld7GOT+2XnuVZjjA/K32uUnMQ==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.8", + "css-what": "^6.1.0", + "cssesc": "^3.0.0", + "csstype": "^3.0.7", + "dedent": "^1.5.3", + "deep-object-diff": "^1.1.9", + "deepmerge": "^4.2.2", + "lru-cache": "^10.4.3", + "media-query-parser": "^2.0.2", + "modern-ahocorasick": "^1.0.0", + "picocolors": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/dynamic": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vanilla-extract/dynamic/-/dynamic-2.1.4.tgz", + "integrity": "sha512-7+Ot7VlP3cIzhJnTsY/kBtNs21s0YD7WI1rKJJKYP56BkbDxi/wrQUWMGEczKPUDkJuFcvbye+E2ub1u/mHH9w==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/private": "^1.0.8" + } + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.9.tgz", + "integrity": "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==", + "license": "MIT" + }, + "node_modules/@vanilla-extract/sprinkles": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@vanilla-extract/sprinkles/-/sprinkles-1.6.4.tgz", + "integrity": "sha512-lW3MuIcdIeHKX81DzhTnw68YJdL1ial05exiuvTLJMdHXQLKcVB93AncLPajMM6mUhaVVx5ALZzNHMTrq/U9Hg==", + "license": "MIT", + "peerDependencies": { + "@vanilla-extract/css": "^1.0.0" + } + }, "node_modules/@wagmi/connectors": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-6.1.4.tgz", @@ -6302,13 +6403,61 @@ "node": "*" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", - "devOptional": true, "license": "MIT" }, + "node_modules/cuer": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/cuer/-/cuer-0.0.3.tgz", + "integrity": "sha512-f/UNxRMRCYtfLEGECAViByA3JNflZImOk11G9hwSd+44jvzrc99J35u5l+fbdQ2+ZG441GvOpaeGYBmWquZsbQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "qr": "~0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6417,6 +6566,20 @@ "node": ">=0.10" } }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6424,6 +6587,21 @@ "license": "MIT", "peer": true }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8919,6 +9097,15 @@ "is-buffer": "~1.1.6" } }, + "node_modules/media-query-parser": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", + "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + } + }, "node_modules/micro-ftch": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/micro-ftch/-/micro-ftch-0.3.1.tgz", @@ -8987,6 +9174,12 @@ } } }, + "node_modules/modern-ahocorasick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", + "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9758,6 +9951,15 @@ "node": ">=6" } }, + "node_modules/qr": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/qr/-/qr-0.5.3.tgz", + "integrity": "sha512-BSrGdNXa8z6PfEYWtvITV21mQ4asR4UCj38Fa3MUUoFAtYzFK/swEQXF+OeBuNbHPFfs3PzpZuK0BXizWXgFOQ==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 20.19.0" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -10869,6 +11071,32 @@ "integrity": "sha512-6RD4xOxp26BTZLopNbqT2iErqNhQZZWb5m5F07/UwGhldGvOAKOl41pZ3fxsFp04bNL+PbgMjNfb6IvJAC/uYQ==", "license": "MIT" }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",