diff --git a/app/components/ConnectButton.tsx b/app/components/ConnectButton.tsx index d6b5ee2..80bef80 100644 --- a/app/components/ConnectButton.tsx +++ b/app/components/ConnectButton.tsx @@ -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(null); const [isConnecting, setIsConnecting] = useState(false); const dropdownRef = useRef(null); const router = useRouter(); + const openManualConnect = () => { + setShowHelpModal(false); + setConnectModalView('manual'); + }; + // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -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'); } }; @@ -127,9 +137,17 @@ export default function ConnectButton() { )} - setShowHelpModal(false)} + setShowHelpModal(false)} + onManualConnect={openManualConnect} + /> + + setConnectModalView(null)} + onXverse={handleXverseConnect} /> ); diff --git a/app/components/LightningBalance.tsx b/app/components/LightningBalance.tsx index 491433e..4025be3 100644 --- a/app/components/LightningBalance.tsx +++ b/app/components/LightningBalance.tsx @@ -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 { @@ -32,9 +33,12 @@ export default function LightningBalance({ lightningToken, isLightningAuthenticated, isInitialized, + isConnected, address, addressPublicKey, + walletType, connectWithLightning, + signMessage, } = useWallet(); const [balance, setBalance] = useState(null); const [walletInfo, setWalletInfo] = useState(null); @@ -45,6 +49,7 @@ export default function LightningBalance({ const [isResetting, setIsResetting] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [isConnecting, setIsConnecting] = useState(false); + const [showConnectModal, setShowConnectModal] = useState(false); const [isFetching, setIsFetching] = useState(false); const containerClassName = getCollapsibleContainerClassName( `bg-background p-4 sm:p-6 shadow-md border border-border ${className}`.trim(), @@ -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); @@ -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', @@ -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 { @@ -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; @@ -296,26 +304,10 @@ export default function LightningBalance({ {!collapsed && ( <> - {!isLightningAuthenticated ? ( + {!isLightningAuthenticated && !isManual ? (
) : (
- {displayLnAddress && ( + {displayLnAddress ? (

LN Address

@@ -399,7 +391,7 @@ export default function LightningBalance({ Reset )} - {isOwner && isLightningAuthenticated && ( + {isOwner && (isLightningAuthenticated || isManual) && (
- )} + ) : isManual && isOwner ? ( +
+ +
+ ) : null} )} + {isManual && ( +

+ Balance and withdrawals require connecting with Xverse. +

+ )} + {error && (
{error} @@ -432,6 +439,12 @@ export default function LightningBalance({ onUpdate={fetchCombinedData} /> + setShowConnectModal(false)} + onXverse={handleConnectXverse} + /> + {/* Withdraw Modal */} {isWithdrawModalOpen && balance !== null && lightningToken && accountData?.btc_address && ( void; } +function buildClaimMessage( + username: string, + tier: string, + tierSlotIndex: number, + destinationAddress: string, +): string { + return `${username}|${tier}|${tierSlotIndex}|${destinationAddress}`; +} + +// Surface real failures but stay quiet when the user simply cancels signing. +function getClaimErrorMessage(err: unknown): string | null { + if (err instanceof SignCancelledError) { + return null; + } + return err instanceof Error ? err.message : "Failed to claim"; +} + function buildSlots(data: Eligibility): Slot[] { const slots: Slot[] = []; for (const [tier, inscriptionIds] of Object.entries(data.assigned_inscription_ids ?? {})) { @@ -88,7 +106,7 @@ function CodeAssetImage() { } export default function DispenserClaim({ userId, className = "", collapsed = false, onToggle }: DispenserClaimProps) { - const { address, isInitialized } = useWallet(); + const { address, walletType, isInitialized, signMessage } = useWallet(); const [eligibility, setEligibility] = useState(null); const [loading, setLoading] = useState(true); const [claimingSlot, setClaimingSlot] = useState(null); @@ -96,9 +114,14 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal const [error, setError] = useState(null); // const [txHex, setTxHex] = useState(null); const [copiedSlot, setCopiedSlot] = useState(null); + // Manual wallets can't auto-supply an Ordinals address, so they enter a + // destination address before signing the claim. + const [manualSlot, setManualSlot] = useState(null); + const [manualDestination, setManualDestination] = useState(""); const [showRewards, setShowRewards] = useState(false); const isOwner = address === userId; + const isManual = walletType === "manual"; const handleCopyLink = async (inscriptionId: string, slotIndex: number) => { const url = `${window.location.origin}/dispenser/share/${inscriptionId}`; @@ -134,6 +157,33 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal } }, [isInitialized, fetchEligibility]); + const submitClaim = useCallback(async ( + tier: string, + tierSlotIndex: number, + destinationAddress: string, + signature: string, + ) => { + const response = await fetch("/api/dispenser/claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: userId, + tier, + slot: tierSlotIndex, + destination_address: destinationAddress, + signature, + }), + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || "Failed to submit claim"); + } + + return data ?? {}; + }, [userId]); + const handleClaim = async (tier: string, slotIndex: number, tierSlotIndex: number) => { if (!address) return; @@ -142,7 +192,7 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal // setTxHex(null); try { - const { request, MessageSigningProtocols, AddressPurpose } = await import("@sats-connect/core"); + const { request, AddressPurpose } = await import("@sats-connect/core"); const accountsResponse = await request("getAccounts", { purposes: [AddressPurpose.Ordinals], @@ -162,49 +212,68 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal } const destinationAddress = ordinalsAccount.address; - const message = `${userId}|${tier}|${tierSlotIndex}|${destinationAddress}`; + const message = buildClaimMessage(userId, tier, tierSlotIndex, destinationAddress); - const signResponse = await request("signMessage", { - address: address, - message: message, - protocol: MessageSigningProtocols.BIP322, + const data = await signMessage({ + address, + message, + submit: (signature: string) => submitClaim(tier, tierSlotIndex, destinationAddress, signature), }); - if (signResponse.status !== "success") { - throw new Error("Failed to sign message"); - } + // setTxHex(data.hex); + setLocalClaimed((prev) => new Set(prev).add(slotIndex)); - let signature: string; - if ( - signResponse.result && - typeof signResponse.result === "object" && - "signature" in signResponse.result - ) { - signature = signResponse.result.signature; - } else { - throw new Error("Unexpected signature format"); + // Link assets dispense a redemption URL, redirect to it + if (data.claim_url) { + window.location.assign(data.claim_url); + return; } + } catch (err) { + console.error("Claim error:", err); + setError(getClaimErrorMessage(err)); + } finally { + setClaimingSlot(null); + } + }; - const response = await fetch("/api/dispenser/claim", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: userId, - tier, - slot: tierSlotIndex, - destination_address: destinationAddress, - signature, - }), - }); + const openManualClaim = (slot: Slot) => { + setManualSlot(slot); + setManualDestination(""); + setError(null); + }; - const data = await response.json(); + const closeManualClaim = useCallback(() => { + setManualSlot(null); + setManualDestination(""); + setError(null); + }, []); - if (!response.ok) { - throw new Error(data.error || "Failed to submit claim"); - } + const handleManualClaim = async () => { + if (!manualSlot) return; - // setTxHex(data.hex); - setLocalClaimed((prev) => new Set(prev).add(slotIndex)); + const destinationAddress = normalizeBitcoinAddress(manualDestination); + if (!isValidBitcoinAddress(destinationAddress)) { + setError("Enter a valid destination Bitcoin address"); + return; + } + + const slot = manualSlot; + setClaimingSlot(slot.index); + setError(null); + // Close the destination prompt so the signing modal is visible. + setManualSlot(null); + + try { + const message = buildClaimMessage(userId, slot.tier, slot.tierSlotIndex, destinationAddress); + + const data = await signMessage({ + address: userId, + message, + submit: (signature: string) => submitClaim(slot.tier, slot.tierSlotIndex, destinationAddress, signature), + }); + + setLocalClaimed((prev) => new Set(prev).add(slot.index)); + setManualDestination(""); // Link assets dispense a redemption URL, redirect to it if (data.claim_url) { @@ -212,13 +281,24 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal return; } } catch (err) { - console.error("Claim error:", err); - setError(err instanceof Error ? err.message : "Failed to claim"); + console.error("Manual claim error:", err); + setError(getClaimErrorMessage(err)); } finally { setClaimingSlot(null); } }; + useEffect(() => { + if (!manualSlot) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === "Escape") closeManualClaim(); + }; + + window.addEventListener("keydown", handleEscKey); + return () => window.removeEventListener("keydown", handleEscKey); + }, [manualSlot, closeManualClaim]); + // The panel is always shown so users can browse available rewards const slots = eligibility ? buildSlots(eligibility).map((slot) => ({ @@ -275,7 +355,9 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal
{slot.claimed && isCodeAsset && isOwner && (
)**/} - {!collapsed && error && ( + {!collapsed && error && !manualSlot && (
{error}
)} + + {manualSlot && ( +
{ + if (event.target === event.currentTarget) { + event.stopPropagation(); + closeManualClaim(); + } + }} + > +
event.stopPropagation()} + > +
+

Claim Inscription

+ +
+ +
+
+ Enter the Ordinals address where the inscription should be sent. You'll then + sign a message with your mining address to authorize the claim. +
+ +
+ + setManualDestination(event.target.value)} + placeholder="bc1p..." + autoFocus + className="w-full bg-secondary text-foreground px-3 py-2 border border-border focus:outline-none focus:border-accent-3 font-mono text-sm" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ )}
setShowRewards(false)} /> diff --git a/app/components/icons.tsx b/app/components/icons.tsx index ad34a3f..866914f 100644 --- a/app/components/icons.tsx +++ b/app/components/icons.tsx @@ -282,3 +282,40 @@ export const CopyIcon: React.FC = ({ className = "h-5 w-5" }) => ( ); + +export const SettingsIcon: React.FC = ({ className = "h-5 w-5" }) => ( + + + + +); + +export const XverseLogo: React.FC = ({ className = "h-5 w-5" }) => ( + + + + + +); diff --git a/app/components/modals/ConnectModal.tsx b/app/components/modals/ConnectModal.tsx deleted file mode 100644 index 91e8834..0000000 --- a/app/components/modals/ConnectModal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useWallet } from '@/app/hooks/useWallet'; - -interface ConnectModalProps { - isOpen: boolean; - onClose: () => void; -} - -export default function ConnectModal({ isOpen, onClose }: ConnectModalProps) { - const { connectWithLightning, connect } = useWallet(); - const router = useRouter(); - const [isConnecting, setIsConnecting] = useState(false); - const [isCreating, setIsCreating] = useState(false); - - // Close modal on escape key - useEffect(() => { - const handleEscKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - if (isOpen) { - window.addEventListener('keydown', handleEscKey); - } - - return () => { - window.removeEventListener('keydown', handleEscKey); - }; - }, [isOpen, onClose]); - - // Reset state when modal closes - useEffect(() => { - if (!isOpen) { - setIsConnecting(false); - setIsCreating(false); - } - }, [isOpen]); - - const handleConnectXverse = async () => { - setIsConnecting(true); - try { - const result = await connectWithLightning(); - if (result) { - onClose(); - router.push(`/user/${result.address}`); - } - } catch (err) { - console.error('Failed to connect:', err); - } finally { - setIsConnecting(false); - } - }; - - const handleCreateAccount = async () => { - setIsCreating(true); - try { - // For now, this will also use Xverse to create a new account - // In the future, this could generate a new wallet or use a different method - const address = await connect(); - if (address) { - onClose(); - router.push(`/user/${address}`); - } - } catch (err) { - console.error('Failed to create account:', err); - } finally { - setIsCreating(false); - } - }; - - const handleBackdropClick = (e: React.MouseEvent) => { - // Close modal if clicking on the backdrop (not the modal content) - if (e.target === e.currentTarget) { - onClose(); - } - }; - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - - -
- - - -
-
-
- ); -} - diff --git a/app/components/modals/CreateOrderModal.tsx b/app/components/modals/CreateOrderModal.tsx index 4467b4a..738cc93 100644 --- a/app/components/modals/CreateOrderModal.tsx +++ b/app/components/modals/CreateOrderModal.tsx @@ -23,7 +23,7 @@ const phdToSlider = (phd: number) => ((phd - MIN_PHD) / (MAX_PHD - MIN_PHD)) * 1 const sliderToPhd = (pos: number) => Math.round(MIN_PHD + (pos / 100) * (MAX_PHD - MIN_PHD)); export default function CreateOrderModal({ isOpen, onClose, onCreated, address, hashPrice, halt }: CreateOrderModalProps) { - const { address: walletAddress, isConnected } = useWallet(); + const { address: walletAddress, isConnected, walletType } = useWallet(); const [error, setError] = useState(null); const [selectedPhd, setSelectedPhd] = useState(1); const [editing, setEditing] = useState(false); @@ -345,7 +345,7 @@ export default function CreateOrderModal({ isOpen, onClose, onCreated, address, )}
- {isConnected && ( + {isConnected && walletType !== 'manual' && ( + )} diff --git a/app/components/modals/LightningModal.tsx b/app/components/modals/LightningModal.tsx index aad8c17..7a8ca52 100644 --- a/app/components/modals/LightningModal.tsx +++ b/app/components/modals/LightningModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; -import { useWallet } from '@/app/hooks/useWallet'; +import { useWallet, SignCancelledError } from '@/app/hooks/useWallet'; import type { AccountData, WalletInfo, CombinedAccountResponse } from '@/app/api/account/types'; interface LightningModalProps { @@ -11,7 +11,7 @@ interface LightningModalProps { } export default function LightningModal({ isOpen, onClose, onUpdate }: LightningModalProps) { - const { address, lightningToken } = useWallet(); + const { address, lightningToken, signMessage, walletType } = useWallet(); const [accountData, setAccountData] = useState(null); const [walletInfo, setWalletInfo] = useState(null); const [balance, setBalance] = useState(null); @@ -107,54 +107,38 @@ export default function LightningModal({ isOpen, onClose, onUpdate }: LightningM setError(null); try { - // Request signature for the Lightning address using BIP322 - // We'll use the wallet's signMessage functionality - const { request, MessageSigningProtocols } = await import('@sats-connect/core'); - - const signResponse = await request('signMessage', { - address: address, + // Sign the Lightning address (BIP322) and submit the update + const updatedData = await signMessage({ + address, message: newLnAddress, - protocol: MessageSigningProtocols.BIP322 - }); + submit: async (signature: string) => { + const response = await fetch('/api/account/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + btc_address: address, + ln_address: newLnAddress, + signature: signature, + }), + }); - if (signResponse.status !== 'success') { - throw new Error('Failed to sign message'); - } + if (!response.ok) { + let errorMessage = 'Failed to update Lightning address'; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorMessage; + } catch { + // If response is not JSON, use default error message + } + throw new Error(errorMessage); + } - 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', - headers: { - 'Content-Type': 'application/json', + return await response.json() as AccountData; }, - body: JSON.stringify({ - btc_address: address, - ln_address: newLnAddress, - signature: signature, - }), }); - if (!response.ok) { - let errorMessage = 'Failed to update Lightning address'; - try { - const errorData = await response.json(); - errorMessage = errorData.error || errorMessage; - } catch { - // If response is not JSON, use default error message - } - throw new Error(errorMessage); - } - - const updatedData: AccountData = await response.json(); // Update modal state immediately with returned data setAccountData(updatedData); setNewLnAddress(updatedData.ln_address || ''); @@ -168,6 +152,7 @@ export default function LightningModal({ isOpen, onClose, onUpdate }: LightningM onUpdate(); } } catch (err) { + if (err instanceof SignCancelledError) return; console.error('Error updating Lightning address:', err); setError(err instanceof Error ? err.message : 'Failed to update Lightning address'); } finally { @@ -185,6 +170,7 @@ export default function LightningModal({ isOpen, onClose, onUpdate }: LightningM const displayLnAddress = accountData?.ln_address || ''; const hasAccountData = accountData !== null; const hasLightningData = walletInfo !== null && balance !== null; + const canEdit = hasAccountData || walletType === 'manual'; const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { @@ -227,7 +213,7 @@ export default function LightningModal({ isOpen, onClose, onUpdate }: LightningM {/* Current Lightning Address */}

Current Lightning Address

- {isEditing && hasAccountData ? ( + {isEditing && canEdit ? (
) : (
- {hasAccountData ? ( + {canEdit ? (
setIsEditing(true)} className="flex items-center justify-between bg-secondary p-3 border border-border cursor-pointer hover:bg-secondary/80 transition-colors" diff --git a/app/components/modals/ManualSignModal.tsx b/app/components/modals/ManualSignModal.tsx new file mode 100644 index 0000000..0b513bc --- /dev/null +++ b/app/components/modals/ManualSignModal.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export interface ManualSignRequest { + message: string; + address: string | null; +} + +interface ManualSignModalProps { + request: ManualSignRequest | null; + onSubmit: (signature: string) => Promise; + onCancel: () => void; +} + +export default function ManualSignModal({ request, onSubmit, onCancel }: ManualSignModalProps) { + const [signature, setSignature] = useState(''); + const [copiedField, setCopiedField] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset fields whenever a new signing request opens + useEffect(() => { + setSignature(''); + setCopiedField(null); + setSubmitting(false); + setError(null); + }, [request]); + + useEffect(() => { + if (!request) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !submitting) onCancel(); + }; + + window.addEventListener('keydown', handleEscKey); + return () => window.removeEventListener('keydown', handleEscKey); + }, [request, submitting, onCancel]); + + if (!request) return null; + + const trimmedSignature = signature.trim(); + + const copyToClipboard = async (value: string, field: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleSubmit = async () => { + if (!trimmedSignature || submitting) return; + + setSubmitting(true); + setError(null); + + try { + await onSubmit(trimmedSignature); + } catch (err) { + console.error('Submit signature error:', err); + setError(err instanceof Error ? err.message : 'Failed to submit signature'); + } finally { + setSubmitting(false); + } + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget && !submitting) onCancel(); + }; + + const copyButton = (value: string, field: string) => ( + + ); + + return ( +
+
event.stopPropagation()} + > +
+

Sign Message

+ +
+ +
+
+
+ Address + {copyButton(request.address ?? '', 'address')} +
+
+ {request.address} +
+
+ +
+
+ + {copyButton(request.message, 'message')} +
+