feat: add connector wallet components (ConnectButton, WalletModal)

This commit is contained in:
thesn10
2026-06-24 23:33:10 +02:00
parent 0e204517f2
commit 5658e4c3b8
4 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
'use client';
import { useConnector } from '@solana/connector/react';
import { useState } from 'react';
import { WalletModal } from './wallet-modal';
import { WalletDropdownContent } from './wallet-dropdown-content';
import { Button } from '@/components/ui-base/button';
import { Menu, MenuTrigger, MenuPortal, MenuPositioner, MenuPopup } from '@/components/ui-base/menu';
import { Wallet, ChevronDown } from 'lucide-react';
interface ConnectButtonProps {
className?: string;
}
// Custom Avatar component for Base UI
function Avatar({
src,
alt,
fallback,
className,
}: {
src?: string;
alt?: string;
fallback?: React.ReactNode;
className?: string;
}) {
const [hasError, setHasError] = useState(false);
return (
<div className={`relative flex shrink-0 overflow-hidden rounded-full ${className}`}>
{src && !hasError ? (
<img
src={src}
alt={alt}
className="aspect-square h-full w-full object-cover"
onError={() => setHasError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-muted">{fallback}</div>
)}
</div>
);
}
// Spinner component
function Spinner({ className }: { className?: string }) {
return (
<div
className={`animate-spin rounded-full border-2 border-current border-t-transparent ${className || 'h-4 w-4'}`}
/>
);
}
export function ConnectButton({ className }: ConnectButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const { isConnected, isConnecting, account, connector } = useConnector();
if (isConnected && account && connector) {
const shortAddress = `${account.slice(0, 4)}...${account.slice(-4)}`;
const walletIcon = connector.icon || undefined;
return (
<Menu>
<MenuTrigger className={`h-8 px-3 ${className || ''}`}>
<Avatar
src={walletIcon}
alt={connector.name}
fallback={<Wallet className="h-3 w-3" />}
className="h-5 w-5"
/>
<span className="text-xs">{shortAddress}</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</MenuTrigger>
<MenuPortal>
<MenuPositioner sideOffset={8} align="end">
<MenuPopup className="rounded-[20px] p-0 outline-none">
<WalletDropdownContent
selectedAccount={account}
walletIcon={walletIcon}
walletName={connector.name}
/>
</MenuPopup>
</MenuPositioner>
</MenuPortal>
</Menu>
);
}
// Show loading button when connecting (but modal stays rendered)
const buttonContent = isConnecting ? (
<>
<Spinner className="h-4 w-4" />
<span className="text-xs">Connecting...</span>
</>
) : (
<span className="!text-[14px]">Connect Wallet</span>
);
return (
<>
<Button size="sm" variant="outline" onClick={() => setIsModalOpen(true)} className={className}>
{buttonContent}
</Button>
<WalletModal open={isModalOpen} onOpenChange={setIsModalOpen} />
</>
);
}

View File

@@ -0,0 +1,45 @@
/* eslint-disable @next/next/no-img-element */
import { cn } from '@/lib/utils';
interface WalletIconSource {
id: string;
name: string;
icon?: string | null;
}
interface HiddenWalletIconsProps {
wallets: WalletIconSource[];
maxIcons?: number;
className?: string;
}
export function HiddenWalletIcons({ wallets, maxIcons = 4, className }: HiddenWalletIconsProps) {
const previewWallets = wallets.slice(0, maxIcons);
const placeholderCount = Math.max(0, maxIcons - previewWallets.length);
return (
<div className={cn('grid grid-cols-2 gap-1', className)} aria-hidden="true">
{previewWallets.map(wallet => (
<div
key={wallet.id}
className="relative h-4.5 w-4.5 overflow-hidden rounded-full border border-border bg-muted"
>
{wallet.icon ? (
<img
src={wallet.icon}
alt=""
className="h-full w-full object-cover"
draggable={false}
onError={e => {
e.currentTarget.style.display = 'none';
}}
/>
) : null}
</div>
))}
{Array.from({ length: placeholderCount }).map((_, index) => (
<div key={`placeholder-${index}`} className="h-4.5 w-4.5 rounded-full border border-border bg-muted" />
))}
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client'
import { useConnector } from '@solana/connector/react'
import { useState } from 'react'
interface Props {
selectedAccount: string
walletIcon?: string
walletName: string
}
export function WalletDropdownContent({ selectedAccount, walletIcon, walletName }: Props) {
const { disconnectWallet } = useConnector()
const [copied, setCopied] = useState(false)
function copyAddress() {
navigator.clipboard.writeText(selectedAccount)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<div
style={{
width: 230,
background: 'var(--bg2)',
border: '1px solid var(--bd)',
borderRadius: 12,
padding: 12,
}}
>
{walletIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<img src={walletIcon} width={20} height={20} alt={walletName} style={{ borderRadius: 4 }} />
<span style={{ fontSize: 13, fontWeight: 600 }}>{walletName}</span>
</div>
)}
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 6 }}>
CONNECTED WALLET
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: 12,
wordBreak: 'break-all',
lineHeight: 1.5,
color: 'var(--tx)',
marginBottom: 10,
}}
>
{selectedAccount}
</div>
<button
onClick={copyAddress}
style={{
width: '100%',
marginBottom: 6,
padding: 8,
borderRadius: 8,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
fontSize: 12,
fontWeight: 500,
}}
>
{copied ? 'Copied!' : 'Copy address'}
</button>
<button
onClick={() => disconnectWallet()}
style={{
width: '100%',
padding: 8,
borderRadius: 8,
background: 'transparent',
border: '1px solid var(--danger)',
fontSize: 12,
fontWeight: 500,
color: 'var(--danger)',
}}
>
Disconnect
</button>
</div>
)
}

View File

@@ -0,0 +1,435 @@
'use client';
import { useConnector, type WalletConnectorId, type WalletConnectorMetadata } from '@solana/connector/react';
import { Dialog, DialogContent, DialogTitle, DialogClose } from '@/components/ui-base/dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui-base/collapsible';
import { Button } from '@/components/ui-base/button';
import { HiddenWalletIcons } from '@/components/connector/shared/hidden-wallet-icons';
import { Wallet, ExternalLink, X } from 'lucide-react';
import { useState, useEffect } from 'react';
interface WalletModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Custom Avatar component
function Avatar({
src,
alt,
fallback,
className,
}: {
src?: string;
alt?: string;
fallback?: React.ReactNode;
className?: string;
}) {
const [hasError, setHasError] = useState(false);
return (
<div className={`relative flex shrink-0 overflow-hidden rounded-full ${className}`}>
{src && !hasError ? (
<img
src={src}
alt={alt}
className="aspect-square h-full w-full object-cover"
onError={() => setHasError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-muted">{fallback}</div>
)}
</div>
);
}
// Badge component
function Badge({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={`inline-flex items-center rounded-md border border-primary/10 px-2.5 py-0.5 text-xs font-semibold transition-colors bg-secondary text-secondary-foreground hover:bg-secondary/80 ${className || ''}`}
>
{children}
</span>
);
}
// Spinner component
function Spinner({ className }: { className?: string }) {
return (
<div
className={`animate-spin rounded-full border-2 border-current border-t-transparent ${className || 'h-4 w-4'}`}
/>
);
}
// Separator component
function Separator({ className }: { className?: string }) {
return <div className={`shrink-0 bg-border h-[1px] w-full ${className || ''}`} />;
}
// Error Alert component
function ErrorAlert({ message, onDismiss }: { message: string; onDismiss: () => void }) {
return (
<div className="flex items-start gap-3 rounded-[12px] border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="flex-1">
<p className="font-medium">Connection failed</p>
<p className="text-xs opacity-90 mt-0.5">{message}</p>
</div>
<button
onClick={onDismiss}
className="shrink-0 rounded-md p-1 hover:bg-destructive/20 transition-colors"
aria-label="Dismiss error"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
export function WalletModal({ open, onOpenChange }: WalletModalProps) {
const { walletStatus, isConnecting, connectorId, connectors, connectWallet, disconnectWallet } = useConnector();
const status = walletStatus.status;
const [connectingConnectorId, setConnectingConnectorId] = useState<WalletConnectorId | null>(null);
const [isClient, setIsClient] = useState(false);
const [recentlyConnectedConnectorId, setRecentlyConnectedConnectorId] = useState<WalletConnectorId | null>(null);
const [isOtherWalletsOpen, setIsOtherWalletsOpen] = useState(false);
const [errorConnectorId, setErrorConnectorId] = useState<WalletConnectorId | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
const recent = localStorage.getItem('recentlyConnectedConnectorId');
if (recent) {
setRecentlyConnectedConnectorId(recent as WalletConnectorId);
}
}, []);
useEffect(() => {
if (status !== 'connected') return;
if (!connectorId) return;
localStorage.setItem('recentlyConnectedConnectorId', connectorId);
setRecentlyConnectedConnectorId(connectorId);
}, [status, connectorId]);
function cancelConnection() {
setConnectingConnectorId(null);
disconnectWallet().catch(() => {});
}
// Clear error state when modal closes or user tries another wallet
const clearError = () => {
setErrorConnectorId(null);
setErrorMessage(null);
};
const handleSelectWallet = async (connector: WalletConnectorMetadata) => {
clearError();
setConnectingConnectorId(connector.id);
try {
await connectWallet(connector.id);
localStorage.setItem('recentlyConnectedConnectorId', connector.id);
setRecentlyConnectedConnectorId(connector.id);
onOpenChange(false);
} catch (error) {
// Extract user-friendly error message
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
if (message.includes('Connection cancelled')) return;
// Set error state for UI feedback
setErrorConnectorId(connector.id);
setErrorMessage(message);
// Log for telemetry/debugging (includes full error details)
console.error('Failed to connect wallet:', {
wallet: connector.name,
connectorId: connector.id,
error,
message,
timestamp: new Date().toISOString(),
});
} finally {
setConnectingConnectorId(null);
}
};
const readyConnectors = connectors.filter(c => c.ready);
const notReadyConnectors = connectors.filter(c => !c.ready);
const sortedReadyConnectors = [...readyConnectors].sort((a, b) => {
const aIsRecent = recentlyConnectedConnectorId === a.id;
const bIsRecent = recentlyConnectedConnectorId === b.id;
if (aIsRecent && !bIsRecent) return -1;
if (!aIsRecent && bIsRecent) return 1;
return 0;
});
const primaryWallets = sortedReadyConnectors.slice(0, 3);
const otherWallets = sortedReadyConnectors.slice(3);
const getInstallUrl = (walletName: string, walletUrl?: string): string | undefined => {
// Prefer wallet metadata URL if available
if (walletUrl) return walletUrl;
// Known wallet install URLs
const name = walletName.toLowerCase();
if (name.includes('phantom')) return 'https://phantom.app';
if (name.includes('solflare')) return 'https://solflare.com';
if (name.includes('backpack')) return 'https://backpack.app';
if (name.includes('glow')) return 'https://glow.app';
if (name.includes('coinbase')) return 'https://www.coinbase.com/wallet';
if (name.includes('ledger')) return 'https://www.ledger.com';
if (name.includes('trust')) return 'https://trustwallet.com';
if (name.includes('exodus')) return 'https://www.exodus.com';
// Return undefined for unknown wallets to avoid misleading users
return undefined;
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
clearError();
if (isConnecting || connectingConnectorId) {
cancelConnection();
}
}
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false} className="max-w-md rounded-[24px] p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold">Connect your wallet</DialogTitle>
<DialogClose className="rounded-[16px] h-8 w-8 p-2 border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground inline-flex items-center justify-center cursor-pointer">
<X className="h-3 w-3" />
</DialogClose>
</div>
<div className="space-y-4">
{/* Error Alert */}
{errorMessage && <ErrorAlert message={errorMessage} onDismiss={clearError} />}
{!isClient ? (
<div className="text-center py-8">
<Spinner className="h-6 w-6 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Detecting wallets...</p>
</div>
) : (
<>
{primaryWallets.length > 0 && (
<div className="space-y-2">
<div className="grid gap-2">
{primaryWallets.map(connector => {
const isThisConnecting =
connectingConnectorId === connector.id ||
(isConnecting && connectorId === connector.id);
const isRecent = recentlyConnectedConnectorId === connector.id;
const hasError = errorConnectorId === connector.id;
return (
<Button
key={connector.id}
variant="outline"
className={`h-auto justify-between p-4 rounded-[16px] w-full ${
hasError
? 'border-destructive/50 bg-destructive/5 hover:bg-destructive/10'
: ''
}`}
onClick={() => handleSelectWallet(connector)}
disabled={isThisConnecting}
>
<div className="flex items-center gap-3 flex-1">
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="font-semibold text-md">
{connector.name}
</span>
{isRecent && (
<Badge className="text-xs">Recent</Badge>
)}
</div>
{isThisConnecting && (
<div className="text-xs text-muted-foreground">
Connecting...
</div>
)}
{hasError && !isConnecting && (
<div className="text-xs text-destructive">
Click to retry
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isThisConnecting && <Spinner className="h-4 w-4" />}
<Avatar
src={connector.icon}
alt={connector.name}
fallback={<Wallet className="h-5 w-5" />}
className="h-10 w-10"
/>
</div>
</Button>
);
})}
</div>
</div>
)}
{otherWallets.length > 0 && (
<>
{primaryWallets.length > 0 && <Separator />}
<Collapsible open={isOtherWalletsOpen} onOpenChange={setIsOtherWalletsOpen}>
<CollapsibleTrigger className="w-full flex items-center justify-between border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground hover:no-underline dark:bg-input/30 dark:border-input dark:hover:bg-input/50 rounded-[16px] px-4 py-2 cursor-pointer active:scale-[0.98]">
<span className="font-semibold text-md">Other Wallets</span>
<HiddenWalletIcons wallets={otherWallets} className="shrink-0" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid gap-2 pt-2">
{otherWallets.map(connector => {
const isThisConnecting =
connectingConnectorId === connector.id ||
(isConnecting && connectorId === connector.id);
const isRecent = recentlyConnectedConnectorId === connector.id;
const hasError = errorConnectorId === connector.id;
return (
<Button
key={connector.id}
variant="outline"
className={`h-auto justify-between p-4 rounded-[16px] w-full ${
hasError
? 'border-destructive/50 bg-destructive/5 hover:bg-destructive/10'
: ''
}`}
onClick={() => handleSelectWallet(connector)}
disabled={isThisConnecting}
>
<div className="flex items-center gap-3 flex-1">
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">
{connector.name}
</span>
{isRecent && (
<Badge className="text-xs">
Recent
</Badge>
)}
</div>
{isThisConnecting && (
<div className="text-xs text-muted-foreground">
Connecting...
</div>
)}
{hasError && !isConnecting && (
<div className="text-xs text-destructive">
Click to retry
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isThisConnecting && (
<Spinner className="h-4 w-4" />
)}
<Avatar
src={connector.icon}
alt={connector.name}
fallback={<Wallet className="h-5 w-5" />}
className="h-10 w-10"
/>
</div>
</Button>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</>
)}
{notReadyConnectors.length > 0 && (
<>
{(primaryWallets.length > 0 || otherWallets.length > 0) && <Separator />}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground px-1">
{readyConnectors.length > 0 ? 'Unavailable Wallets' : 'Wallets'}
</h3>
<div className="grid gap-2">
{notReadyConnectors.slice(0, 3).map(connector => {
const installUrl = getInstallUrl(connector.name);
return (
<div
key={connector.id}
className="flex items-center justify-between p-4 rounded-[16px] w-full border bg-background shadow-xs"
>
<div className="flex items-center gap-3">
<Avatar
src={connector.icon}
alt={connector.name}
fallback={<Wallet className="h-4 w-4" />}
className="h-8 w-8"
/>
<div className="text-left">
<div className="font-medium text-sm">
{connector.name}
</div>
<div className="text-xs text-muted-foreground">
Not available
</div>
</div>
</div>
{installUrl && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 cursor-pointer"
onClick={() => window.open(installUrl, '_blank')}
>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
);
})}
</div>
</div>
</>
)}
{connectors.length === 0 && (
<div className="rounded-lg border border-dashed p-8 text-center">
<Wallet className="mx-auto h-12 w-12 text-muted-foreground mb-3" />
<h3 className="font-semibold mb-2">No Wallets Detected</h3>
<p className="text-sm text-muted-foreground mb-6">
Install a Solana wallet extension to get started
</p>
<div className="flex gap-2 justify-center">
<Button
onClick={() => window.open('https://phantom.app', '_blank')}
className="bg-purple-600 hover:bg-purple-700"
>
Get Phantom
</Button>
<Button
variant="outline"
onClick={() => window.open('https://backpack.app', '_blank')}
>
Get Backpack
</Button>
</div>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}