Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
334 changes: 76 additions & 258 deletions src/components/web3/WalletConnector.tsx
Original file line number Diff line number Diff line change
@@ -1,282 +1,100 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
'use client';

import React, { useCallback, useState } from 'react';
import { Wallet, LogOut, AlertCircle, Loader2, Copy, Check, ChevronDown } from 'lucide-react';
import { useWeb3Wallet, type WalletProvider } from '@/hooks/useWeb3Wallet';
import React, { useState, useEffect } from 'react';
import { scanWalletDependencies, WalletDetectionResult } from '../../utils/web3/walletDetection';

interface WalletConnectorProps {
className?: string;
showBalance?: boolean;
onConnect?: (address: string, provider: WalletProvider) => void;
onDisconnect?: () => void;
/** Optional flag to expose Service Account provider */
showServiceAccount?: boolean;
onConnect?: (walletType: string) => void;
}

/**
* WalletConnector Component
*
* Provides seamless multi-wallet connection experience with support for:
* - MetaMask
* - Starknet (ArgentX, Braavos)
* - WalletConnect
* - Coinbase Wallet
*
* Features:
* - Easy switching between providers
* - Address copy-to-clipboard
* - Connection status display
* - Error handling and recovery
* - Responsive design
*/
export const WalletConnector: React.FC<WalletConnectorProps> = ({
className = '',
showBalance = false,
onConnect,
onDisconnect,
showServiceAccount,
}) => {
const wallet = useWeb3Wallet();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [copiedAddress, setCopiedAddress] = useState(false);

const walletProviders: { id: WalletProvider; name: string; description: string }[] = [
{ id: 'metamask', name: 'MetaMask', description: 'Connect using MetaMask extension' },
{ id: 'starknet', name: 'Starknet', description: 'Connect using ArgentX or Braavos' },
...(showServiceAccount
? [
{
id: 'service',
name: 'Service Account',
description: 'Connect using backend service account',
},
]
: []),
];

/**
* Handle wallet connection
*/
const handleConnect = useCallback(
async (provider: WalletProvider) => {
try {
const result = await wallet.connect(provider);
setIsDropdownOpen(false);
onConnect?.(result.address, result.provider);
} catch (error) {
console.error('[WalletConnector] Connection failed:', error);
}
},
[wallet, onConnect],
);

/**
* Handle wallet disconnection
*/
const handleDisconnect = useCallback(async () => {
await wallet.disconnect();
setIsDropdownOpen(false);
onDisconnect?.();
}, [wallet, onDisconnect]);

/**
* Copy address to clipboard
*/
const handleCopyAddress = useCallback(async () => {
if (!wallet.address) return;

try {
await navigator.clipboard.writeText(wallet.address);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
} catch (error) {
console.error('[WalletConnector] Failed to copy address:', error);
export const WalletConnector: React.FC<WalletConnectorProps> = ({ onConnect }) => {
const [scanResult, setScanResult] = useState<WalletDetectionResult | null>(null);
const [isScanning, setIsScanning] = useState(true);
const [isConnected, setIsConnected] = useState(false);
const [selectedWallet, setSelectedWallet] = useState<string | null>(null);

useEffect(() => {
const scan = async () => {
setIsScanning(true);
// Simulate async scan
await new Promise(resolve => setTimeout(resolve, 500));
const result = scanWalletDependencies();
setScanResult(result);
setIsScanning(false);
};
scan();
}, []);

const handleConnect = async (walletType: string) => {
setSelectedWallet(walletType);
setIsConnected(true);
if (onConnect) {
onConnect(walletType);
}
}, [wallet.address]);

/**
* Format address for display
*/
const formatAddress = (address: string, chars = 4): string => {
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
};

// Not connected state
if (!wallet.isConnected) {
if (isScanning) {
return (
<div className={`relative ${className}`}>
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
disabled={wallet.isConnecting}
className="px-4 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:from-gray-400 disabled:to-gray-500 text-white font-medium rounded-lg transition-all flex items-center gap-2 shadow-lg hover:shadow-xl disabled:shadow-md"
aria-label="Connect wallet"
aria-expanded={isDropdownOpen}
>
{wallet.isConnecting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span className="hidden sm:inline">Connecting...</span>
</>
) : (
<>
<Wallet className="w-4 h-4" />
<span className="hidden sm:inline">Connect Wallet</span>
<span className="sm:hidden">Connect</span>
</>
)}
<ChevronDown
className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className="wallet-connector" role="status" aria-label="Scanning for wallets">
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span className="ml-2 text-gray-600">Scanning for wallets...</span>
</div>
</div>
);
}

{/* Dropdown menu */}
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm">Select Wallet</h3>
</div>

<div className="divide-y divide-gray-200 dark:divide-gray-700">
{walletProviders.map((provider) => (
<button
key={provider.id}
onClick={() => handleConnect(provider.id)}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors group"
aria-label={`Connect ${provider.name}`}
>
<div className="font-medium text-gray-900 dark:text-white text-sm">
{provider.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{provider.description}
</div>
</button>
))}
</div>
</div>
)}

{/* Error message */}
{wallet.error && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-700 dark:text-red-300">{wallet.error}</p>
<button
onClick={() => wallet.clearError()}
className="text-xs text-red-600 dark:text-red-400 hover:underline mt-1 block"
>
Dismiss
</button>
</div>
</div>
)}
if (!scanResult || scanResult.detectedWallets.length === 0) {
return (
<div className="wallet-connector" role="alert" aria-label="No wallets detected">
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 font-medium">No wallets detected</p>
<p className="text-yellow-600 text-sm mt-1">{scanResult?.message}</p>
<a
href="https://www.freighter.app/"
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-3 px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600"
>
Install Freighter
</a>
</div>
</div>
);
}

// Connected state
return (
<div className={`relative ${className}`}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="px-4 py-2.5 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white font-medium rounded-lg transition-all flex items-center gap-2 shadow-lg hover:shadow-xl"
aria-label="Wallet menu"
aria-expanded={isDropdownOpen}
>
<div className="w-2 h-2 bg-green-300 rounded-full animate-pulse" />
<span className="hidden sm:inline">{formatAddress(wallet.address || '')}</span>
<span className="sm:hidden">{formatAddress(wallet.address || '', 3)}</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>

{/* Dropdown menu */}
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
{/* Address section */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold mb-2">
Connected Address
</p>
<div className="flex items-center gap-2">
<div className="flex-1">
<p className="font-mono text-sm font-medium text-gray-900 dark:text-white break-all">
{wallet.address}
</p>
</div>
<div className="wallet-connector" role="region" aria-label="Wallet connection">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-3" aria-live="polite">
{scanResult.message}
</p>
{!isConnected ? (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Select a wallet to connect:</p>
{scanResult.hasFreighter && (
<button
onClick={handleCopyAddress}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
aria-label="Copy address"
title="Copy address"
onClick={() => handleConnect('freighter')}
className="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
aria-label="Connect Freighter wallet"
>
{copiedAddress ? (
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
) : (
<Copy className="w-4 h-4 text-gray-600 dark:text-gray-400" />
)}
Connect Freighter
</button>
</div>
</div>

{/* Provider and chain info */}
<div className="p-4 space-y-3 border-b border-gray-200 dark:border-gray-700">
{wallet.provider && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Provider</p>
<p className="text-sm font-medium text-gray-900 dark:text-white capitalize">
{wallet.provider}
</p>
</div>
)}
{wallet.chainId && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Network</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{wallet.supportedChains[wallet.chainId]?.chainName || wallet.chainId}
</p>
</div>
{scanResult.hasEthereum && (
<button
onClick={() => handleConnect('ethereum')}
className="w-full px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition"
aria-label="Connect Ethereum wallet"
>
Connect Ethereum Wallet
</button>
)}
</div>

{/* Balances section */}
{showBalance && wallet.balances.length > 0 && (
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold mb-3">
Balances
</p>
<div className="space-y-2">
{wallet.balances.map((balance) => (
<div key={balance.token} className="flex justify-between items-center text-sm">
<span className="text-gray-600 dark:text-gray-400">{balance.symbol}</span>
<span className="font-medium text-gray-900 dark:text-white">
{parseFloat(balance.balance).toFixed(4)}
</span>
</div>
))}
</div>
</div>
)}

{/* Disconnect button */}
<button
onClick={handleDisconnect}
className="w-full px-4 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 dark:text-red-400 font-medium flex items-center gap-2 transition-colors"
aria-label="Disconnect wallet"
>
<LogOut className="w-4 h-4" />
<span>Disconnect Wallet</span>
</button>
</div>
)}
) : (
<div className="text-center p-2 bg-green-50 rounded-lg">
<p className="text-green-700">✓ Connected to {selectedWallet}</p>
</div>
)}
</div>
</div>
);
};

export default WalletConnector;
Loading
Loading