Skip to content
Merged
114 changes: 66 additions & 48 deletions app/components/ConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useWallet } from '../hooks/useWallet';
import HelpModal from './modals/HelpModal';
import WalletConnectModal, { WalletConnectView } from './modals/WalletConnectModal';

export default function ConnectButton() {
const { address, isConnected, disconnect, connectWithLightning } = useWallet();
const [showDropdown, setShowDropdown] = useState(false);
const [showHelpModal, setShowHelpModal] = useState(false);
const [connectModalView, setConnectModalView] = useState<WalletConnectView | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const router = useRouter();

const openManualConnect = () => {
setShowHelpModal(false);
setConnectModalView('manual');
};

// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
Expand All @@ -29,54 +36,57 @@ export default function ConnectButton() {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
};

const handleButtonClick = async () => {
const handleXverseConnect = async () => {
setIsConnecting(true);
try {
const result = await connectWithLightning();
if (result) {
router.push(`/user/${result.address}`);
}
// If result is null, user likely cancelled - don't show help modal
} catch (err: unknown) {
// Check if error indicates Xverse is not installed
const errorMessage = (err instanceof Error ? err.message : String(err)) || '';
const errorString = errorMessage.toLowerCase();

// Check for error code/name if err is an object
const errorObj = err && typeof err === 'object' ? err as { code?: string; name?: string } : null;
const errorCode = errorObj?.code;
const errorName = errorObj?.name;

// Common error patterns when Xverse extension is not installed
const isExtensionNotFound = (
errorString.includes('no wallet provider') ||
errorString.includes('wallet provider was found') ||
errorString.includes('extension') ||
errorString.includes('not found') ||
errorString.includes('not installed') ||
errorString.includes('no provider') ||
errorString.includes('provider not found') ||
errorString.includes('window.btc') ||
errorString.includes('sats-connect') ||
errorCode === 'EXTENSION_NOT_FOUND' ||
errorName === 'ExtensionNotFoundError' ||
errorName === 'ProviderNotFoundError'
);

if (isExtensionNotFound) {
// Xverse is not installed, show help modal
setShowHelpModal(true);
} else {
// Other error (user cancelled, network error, etc.), just log it
console.error('Failed to connect:', err);
}
} finally {
setIsConnecting(false);
}
};

const handleButtonClick = () => {
if (isConnected) {
setShowDropdown(!showDropdown);
} else {
setIsConnecting(true);
try {
// Try to connect with Xverse directly
const result = await connectWithLightning();
if (result) {
router.push(`/user/${result.address}`);
}
// If result is null, user likely cancelled - don't show help modal
} catch (err: unknown) {
// Check if error indicates Xverse is not installed
const errorMessage = (err instanceof Error ? err.message : String(err)) || '';
const errorString = errorMessage.toLowerCase();

// Check for error code/name if err is an object
const errorObj = err && typeof err === 'object' ? err as { code?: string; name?: string } : null;
const errorCode = errorObj?.code;
const errorName = errorObj?.name;

// Common error patterns when Xverse extension is not installed
const isExtensionNotFound = (
errorString.includes('no wallet provider') ||
errorString.includes('wallet provider was found') ||
errorString.includes('extension') ||
errorString.includes('not found') ||
errorString.includes('not installed') ||
errorString.includes('no provider') ||
errorString.includes('provider not found') ||
errorString.includes('window.btc') ||
errorString.includes('sats-connect') ||
errorCode === 'EXTENSION_NOT_FOUND' ||
errorName === 'ExtensionNotFoundError' ||
errorName === 'ProviderNotFoundError'
);

if (isExtensionNotFound) {
// Xverse is not installed, show help modal
setShowHelpModal(true);
} else {
// Other error (user cancelled, network error, etc.), just log it
console.error('Failed to connect:', err);
}
} finally {
setIsConnecting(false);
}
setConnectModalView('choice');
}
};

Expand Down Expand Up @@ -127,9 +137,17 @@ export default function ConnectButton() {
)}
</div>

<HelpModal
isOpen={showHelpModal}
onClose={() => setShowHelpModal(false)}
<HelpModal
isOpen={showHelpModal}
onClose={() => setShowHelpModal(false)}
onManualConnect={openManualConnect}
/>

<WalletConnectModal
isOpen={connectModalView !== null}
initialView={connectModalView ?? 'choice'}
onClose={() => setConnectModalView(null)}
onXverse={handleXverseConnect}
/>
</>
);
Expand Down
99 changes: 56 additions & 43 deletions app/components/LightningBalance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useRouter } from "next/navigation";
import CardHeader from "@/app/components/CardHeader";
import { getCollapsibleContainerClassName, shouldToggleCollapse } from "@/app/components/collapsible";
import { LightningIcon } from "@/app/components/icons";
import { useWallet } from "@/app/hooks/useWallet";
import { useWallet, SignCancelledError } from "@/app/hooks/useWallet";
import LightningModal from "@/app/components/modals/LightningModal";
import WithdrawModal from "@/app/components/modals/WithdrawModal";
import WalletConnectModal from "@/app/components/modals/WalletConnectModal";
import type { AccountData, WalletInfo, CombinedAccountResponse } from "@/app/api/account/types";

interface LightningBalanceProps {
Expand All @@ -32,9 +33,12 @@ export default function LightningBalance({
lightningToken,
isLightningAuthenticated,
isInitialized,
isConnected,
address,
addressPublicKey,
walletType,
connectWithLightning,
signMessage,
} = useWallet();
const [balance, setBalance] = useState<number | null>(null);
const [walletInfo, setWalletInfo] = useState<WalletInfo | null>(null);
Expand All @@ -45,6 +49,7 @@ export default function LightningBalance({
const [isResetting, setIsResetting] = useState<boolean>(false);
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [showConnectModal, setShowConnectModal] = useState<boolean>(false);
const [isFetching, setIsFetching] = useState<boolean>(false);
const containerClassName = getCollapsibleContainerClassName(
`bg-background p-4 sm:p-6 shadow-md border border-border ${className}`.trim(),
Expand Down Expand Up @@ -112,6 +117,23 @@ export default function LightningBalance({
}
}, [isInitialized, fetchCombinedData]);

const handleConnectXverse = async () => {
setIsConnecting(true);
setError(null);
try {
const result = await connectWithLightning();
// A null result means the user cancelled the wallet popup; stay quiet.
if (result) {
router.push(`/user/${result.address}`);
}
} catch (err) {
console.error('Connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect wallet');
} finally {
setIsConnecting(false);
}
};

// Open reset confirmation modal
const handleResetClick = () => {
setShowResetConfirm(true);
Expand All @@ -130,28 +152,12 @@ export default function LightningBalance({
setError(null);

try {
// Request signature for the Lightning address using BIP322
const { request, MessageSigningProtocols } = await import('@sats-connect/core');

const signResponse = await request('signMessage', {
address: address,
// Request signature for the Lightning address (BIP322)
const signature = await signMessage({
address,
message: usernameAddress,
protocol: MessageSigningProtocols.BIP322
});

if (signResponse.status !== 'success') {
throw new Error('Failed to sign message');
}

let signature: string;
if (typeof signResponse.result === 'string') {
signature = signResponse.result;
} else if (signResponse.result && typeof signResponse.result === 'object' && 'signature' in signResponse.result) {
signature = signResponse.result.signature;
} else {
throw new Error('Unexpected signature format');
}

// Send update request
const response = await fetch('/api/account/update', {
method: 'POST',
Expand Down Expand Up @@ -183,6 +189,7 @@ export default function LightningBalance({
// Re-fetch to ensure we have the latest data
await fetchCombinedData();
} catch (err) {
if (err instanceof SignCancelledError) return;
console.error('Error resetting Lightning address:', err);
setError(err instanceof Error ? err.message : 'Failed to reset Lightning address');
} finally {
Expand Down Expand Up @@ -242,9 +249,10 @@ export default function LightningBalance({

// Check if the connected wallet owns this account
const isOwner = !userId || address === userId;
const isManual = walletType === 'manual';

const displayLnAddress = accountData?.ln_address || null;
const hasData = isLightningAuthenticated && (balance !== null || walletInfo !== null || accountData !== null);
const hasData = isManual || (isLightningAuthenticated && (balance !== null || walletInfo !== null || accountData !== null));

// Check if username matches lightning address
const usernameWithDomain = walletInfo?.username ? `${walletInfo.username}@sati.pro` : null;
Expand Down Expand Up @@ -296,26 +304,10 @@ export default function LightningBalance({

{!collapsed && (
<>
{!isLightningAuthenticated ? (
{!isLightningAuthenticated && !isManual ? (
<div className="flex justify-center py-8">
<button
onClick={async () => {
setIsConnecting(true);
setError(null);
try {
const result = await connectWithLightning();
if (result) {
router.push(`/user/${result.address}`);
} else {
setError('Failed to connect wallet');
}
} catch (err) {
console.error('Connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect wallet');
} finally {
setIsConnecting(false);
}
}}
onClick={() => (isConnected ? handleConnectXverse() : setShowConnectModal(true))}
disabled={isConnecting}
className="px-6 py-3 bg-foreground text-background text-sm font-medium hover:bg-foreground/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-foreground disabled:opacity-50 disabled:cursor-not-allowed"
>
Expand All @@ -332,7 +324,7 @@ export default function LightningBalance({
<h3 className="text-sm font-medium text-accent-2 mb-2">LN Address</h3>
<div className="bg-secondary p-3 sm:p-4 border border-border flex-1 flex items-center justify-between gap-2 h-[5rem]">
<p className="text-lg sm:text-xl font-semibold whitespace-nowrap overflow-x-auto flex-1 scrollbar-hide">{displayLnAddress}</p>
{isOwner && isLightningAuthenticated && (
{isOwner && (isLightningAuthenticated || isManual) && (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1 px-2 py-1 bg-foreground text-background hover:bg-gray-700 transition-colors text-xs font-medium flex-shrink-0"
Expand Down Expand Up @@ -382,7 +374,7 @@ export default function LightningBalance({
</div>
) : (
<div className="space-y-4">
{displayLnAddress && (
{displayLnAddress ? (
<div className="flex flex-col">
<h3 className="text-sm font-medium text-accent-2 mb-2">LN Address</h3>
<div className="bg-secondary p-3 sm:p-4 border border-border flex items-center justify-between gap-2 h-[5rem]">
Expand All @@ -399,7 +391,7 @@ export default function LightningBalance({
<span>Reset</span>
</button>
)}
{isOwner && isLightningAuthenticated && (
{isOwner && (isLightningAuthenticated || isManual) && (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-1 px-2 py-2 bg-foreground text-background hover:bg-gray-700 transition-colors text-xs font-medium"
Expand All @@ -413,10 +405,25 @@ export default function LightningBalance({
</div>
</div>
</div>
)}
) : isManual && isOwner ? (
<div className="flex justify-center py-8">
<button
onClick={() => setIsModalOpen(true)}
className="px-6 py-3 bg-foreground text-background text-sm font-medium hover:bg-foreground/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-foreground"
>
Set Lightning Address
</button>
</div>
) : null}
</div>
)}

{isManual && (
<p className="mt-4 text-xs text-foreground/50">
Balance and withdrawals require connecting with Xverse.
</p>
)}

{error && (
<div className="mt-4 text-sm text-red-500 bg-red-500/10 p-2 border border-red-500/20">
{error}
Expand All @@ -432,6 +439,12 @@ export default function LightningBalance({
onUpdate={fetchCombinedData}
/>

<WalletConnectModal
isOpen={showConnectModal}
onClose={() => setShowConnectModal(false)}
onXverse={handleConnectXverse}
/>

{/* Withdraw Modal */}
{isWithdrawModalOpen && balance !== null && lightningToken && accountData?.btc_address && (
<WithdrawModal
Expand Down
Loading