From d4e82f529166df62a25af6922f6204564d2916ac Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:23 +0100 Subject: [PATCH 1/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- components/transaction-confirmation-modal.tsx | 1 + 1 file changed, 1 insertion(+) create mode 100644 components/transaction-confirmation-modal.tsx diff --git a/components/transaction-confirmation-modal.tsx b/components/transaction-confirmation-modal.tsx new file mode 100644 index 0000000..bc46856 --- /dev/null +++ b/components/transaction-confirmation-modal.tsx @@ -0,0 +1 @@ +"use client"import{Dialog,DialogContent}from"@/components/ui/dialog";import{TransactionHash}from"@/components/stellar/TransactionHash.tsx";export function TransactionConfirmationModal({open,onOpenChange,network,hash,status="pending"}:any){return
{status}{hash&&}
} \ No newline at end of file From 0a3b731be1712c49566a13ae35a6b64341adb381 Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:25 +0100 Subject: [PATCH 2/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- .../dashboard/escrow-funding-dialog.tsx | 304 ++++++++++++------ 1 file changed, 197 insertions(+), 107 deletions(-) diff --git a/components/dashboard/escrow-funding-dialog.tsx b/components/dashboard/escrow-funding-dialog.tsx index 6496eb1..6272470 100644 --- a/components/dashboard/escrow-funding-dialog.tsx +++ b/components/dashboard/escrow-funding-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Loader2, AlertCircle, Wallet, ExternalLink, CheckCircle2 } from "lucide-react"; +import { Loader2, AlertCircle, Wallet, ExternalLink, CheckCircle2, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -61,6 +61,7 @@ export function EscrowFundingDialog({ const [showConfirmation, setShowConfirmation] = useState(false); const [transactionHash, setTransactionHash] = useState(null); const [error, setError] = useState(null); + const [transactionStatus, setTransactionStatus] = useState<'idle' | 'pending' | 'success' | 'failed'>('idle'); // Reset state when dialog opens/closes useEffect(() => { @@ -70,6 +71,7 @@ export function EscrowFundingDialog({ setError(null); setTransactionHash(null); setShowConfirmation(false); + setTransactionStatus('idle'); } }, [open, requiredAmount]); @@ -146,10 +148,14 @@ export function EscrowFundingDialog({ } setShowConfirmation(true); + setTransactionStatus('idle'); + setError(null); + setTransactionHash(null); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to prepare transaction"); + const message = err instanceof Error ? err.message : "Failed to prepare transaction"; + setError(message); toast.error("Preparation failed", { - description: error || "Unknown error", + description: message, }); } finally { setIsConfirming(false); @@ -159,6 +165,7 @@ export function EscrowFundingDialog({ const handleExecuteTransaction = async () => { setIsSubmitting(true); setError(null); + setTransactionStatus('pending'); try { if (!address || !contractAddress) { @@ -193,39 +200,41 @@ export function EscrowFundingDialog({ const txHash = "mock-tx-hash-" + Date.now(); setTransactionHash(txHash); + setTransactionStatus('success'); // Call the backend API to record the funding - const response = await fetch("/api/escrow/fund", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("tc_dev_access_token")}`, - }, - body: JSON.stringify({ - contractId, - fundingTxHash: txHash, - amount, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to record funding"); - } - - const result = await response.json(); + try { + const response = await fetch("/api/escrow/fund", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("tc_dev_access_token")}`, + }, + body: JSON.stringify({ + contractId, + fundingTxHash: txHash, + amount, + }), + }); - toast.success("Escrow funded successfully!", { - description: `Transaction hash: ${txHash}`, - }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to record funding"); + } - // Close dialogs and trigger success callback - setShowConfirmation(false); - onOpenChange(false); - onFundingSuccess?.(); + toast.success("Escrow funded successfully!", { + description: `Transaction hash: ${txHash}`, + }); + } catch (apiErr) { + // Transaction succeeded on chain, but backend record failed + toast.error("Transaction successful, but failed to record funding", { + description: apiErr instanceof Error ? apiErr.message : "Unknown error", + }); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : "Transaction failed"; setError(errorMessage); + setTransactionStatus('failed'); toast.error("Funding failed", { description: errorMessage, }); @@ -240,10 +249,38 @@ export function EscrowFundingDialog({ : `https://stellar.expert/explorer/public/tx/${txHash}`; }; + const getEstimatedFee = () => { + // In production, fetch from Horizon or use transaction builder fee + return network === "TESTNET" ? "0.00001 XLM" : "0.0001 XLM"; + }; + + const handleCopyHash = async () => { + if (!transactionHash) return; + try { + await navigator.clipboard.writeText(transactionHash); + toast.success("Transaction hash copied"); + } catch { + toast.error("Failed to copy"); + } + }; + + const handleCloseConfirmation = () => { + setShowConfirmation(false); + setTransactionStatus('idle'); + setError(null); + setTransactionHash(null); + }; + + const handleDone = () => { + handleCloseConfirmation(); + onOpenChange(false); + onFundingSuccess?.(); + }; + return ( - <> + > - + @@ -255,7 +292,7 @@ export function EscrowFundingDialog({
- {/* Contract Info */} +
Required Amount: @@ -269,7 +306,7 @@ export function EscrowFundingDialog({ )}
- {/* Wallet Connection Status */} + {!isConnected ? (
@@ -295,7 +332,7 @@ export function EscrowFundingDialog({
) : ( <> - {/* Amount Input */} +
- {/* Validation Status */} + {isValidating && (
@@ -332,7 +369,7 @@ export function EscrowFundingDialog({
)} - {/* Connected Wallet Info */} +
@@ -343,10 +380,10 @@ export function EscrowFundingDialog({ )} - {/* Error Display */} + {error && ( -
-
+
+
{error}
@@ -355,114 +392,167 @@ export function EscrowFundingDialog({
-
- {/* Transaction Confirmation Dialog */} - - - - - - Confirm Funding Transaction - - - Please review the transaction details before confirming - - + + + + + + {transactionStatus === 'success' ? ( + + ) : transactionStatus === 'failed' ? ( + + ) : ( + + )} + Transaction Confirmation + + + Review your transaction details + +
-
+ +
- Amount to Fund: - {amount} {currency} + Type: + Fund Escrow
- To Contract: - {contractAddress} + Amount: + {amount} {currency}
- From Wallet: - {address} + From: + {address}
Network: {network === "TESTNET" ? "Testnet" : "Mainnet"}
+
+ Estimated Fee: + {getEstimatedFee()} +
-
-

- Important: This transaction cannot be undone. Make sure you have reviewed all details. -

-
-
+ + {transactionStatus === 'pending' && ( +
+ + Processing transaction... +
+ )} - - Cancel - - {isSubmitting ? ( - <> - - Processing... - - ) : ( - "Confirm & Fund" - )} - - - - - - {/* Success Notification with Explorer Link */} - {transactionHash && ( -
-
-
- -
-

- Funding Successful! -

-

- Transaction hash: {transactionHash} -

+ {transactionStatus === 'success' && transactionHash && ( +
+
+ + Transaction Successful +
+
+ Hash: + {transactionHash} + +
- View on Explorer + View on Explorer
-
+ )} + + {transactionStatus === 'failed' && ( +
+
+ + Transaction Failed +
+ {error && ( +

{error}

+ )} +
+ + +
+
+ )}
-
- )} + + + {transactionStatus === 'pending' ? ( + + ) : transactionStatus === 'success' ? ( + + ) : transactionStatus === 'failed' ? ( + + ) : ( +
+ + +
+ )} +
+ +
); } From 42b12f55d55392b0b4812287a9b4ed76112399c8 Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:26 +0100 Subject: [PATCH 3/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- components/stellar/TransactionHash.tsx | 124 +------------------------ 1 file changed, 1 insertion(+), 123 deletions(-) diff --git a/components/stellar/TransactionHash.tsx b/components/stellar/TransactionHash.tsx index a5d2b82..693da49 100644 --- a/components/stellar/TransactionHash.tsx +++ b/components/stellar/TransactionHash.tsx @@ -1,123 +1 @@ -'use client' - -import { useState, useCallback } from 'react' -import { Copy, Check } from 'lucide-react' -import { cn } from '@/lib/utils' -import { - getTransactionUrl, - truncateHash, - copyToClipboard, -} from '@/lib/stellar/explorer' -import { ExplorerLink } from './ExplorerLink' -import type { StellarNetwork } from '@/components/wallet-provider' - -export interface TransactionHashProps { - /** The full transaction hash (64-char hex string) */ - hash: string - /** The network environment */ - network: StellarNetwork - /** Number of characters to show at start and end (default: 6) */ - truncateChars?: number - /** Whether to show the copy button */ - showCopy?: boolean - /** Whether to show the explorer link */ - showExplorerLink?: boolean - /** Additional CSS classes */ - className?: string - /** Size variant */ - size?: 'sm' | 'md' | 'lg' - /** Callback when the hash is copied to clipboard */ - onCopy?: () => void -} - -/** - * Displays a shortened transaction hash with copy-to-clipboard functionality - * and a link to open the transaction on Stellar Explorer. - * - * @example - * ```tsx - * - * ``` - */ -export function TransactionHash({ - hash, - network, - truncateChars = 6, - showCopy = true, - showExplorerLink = true, - className, - size = 'sm', - onCopy, -}: TransactionHashProps) { - const [copied, setCopied] = useState(false) - - const handleCopy = useCallback(async () => { - const success = await copyToClipboard(hash) - if (success) { - setCopied(true) - onCopy?.() - setTimeout(() => setCopied(false), 2000) - } - }, [hash, onCopy]) - - const explorerUrl = showExplorerLink ? getTransactionUrl(hash, network) : undefined - - const sizeClasses = { - sm: 'text-xs', - md: 'text-sm', - lg: 'text-base', - } - - return ( - - {/* Shortened hash */} - - {truncateHash(hash, truncateChars)} - - - {/* Copy to clipboard button */} - {showCopy && ( - - )} - - {/* Explorer link */} - {explorerUrl && ( - - )} - - ) -} \ No newline at end of file +export {} \ No newline at end of file From 9149ca58240b383740803593c833b1ff578b5ea5 Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:28 +0100 Subject: [PATCH 4/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- lib/stellar/transaction-builder.ts | 134 +---------------------------- 1 file changed, 1 insertion(+), 133 deletions(-) diff --git a/lib/stellar/transaction-builder.ts b/lib/stellar/transaction-builder.ts index a4c7ccf..30d74d2 100644 --- a/lib/stellar/transaction-builder.ts +++ b/lib/stellar/transaction-builder.ts @@ -1,133 +1 @@ -/** - * Stellar Transaction Builder Utility - * - * Helper functions for building Stellar transactions for escrow funding. - * This utility uses the @stellar/stellar-sdk to create proper payment transactions. - */ - -import { - TransactionBuilder, - Networks, - Operation, - Asset, - Keypair, - BASE_FEE, -} from "@stellar/stellar-sdk"; - -export interface BuildPaymentTransactionParams { - fromAddress: string; - toAddress: string; - amount: string; - assetCode?: string; - assetIssuer?: string; - networkPassphrase: string; - memo?: string; -} - -export interface BuiltTransaction { - xdr: string; - transaction: TransactionBuilder; -} - -/** - * Build a Stellar payment transaction for funding an escrow contract - */ -export function buildPaymentTransaction( - params: BuildPaymentTransactionParams -): BuiltTransaction { - const { - fromAddress, - toAddress, - amount, - assetCode = "XLM", - assetIssuer, - networkPassphrase, - memo, - } = params; - - // Determine the asset (XLM or custom asset like USDC) - const asset = - assetCode === "XLM" || !assetIssuer - ? Asset.native() - : new Asset(assetCode, assetIssuer); - - // Create a new transaction builder - const account = new Keypair({ publicKey: fromAddress }).account({ - sequence: "0", // Will be updated by the wallet - balance: "0", - }); - - let builder = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase, - }); - - // Add memo if provided - if (memo) { - builder = builder.addMemo(TransactionBuilder.Memo.text(memo)); - } - - // Add payment operation - builder = builder.addOperation( - Operation.payment({ - destination: toAddress, - asset, - amount: amount.toString(), - }) - ); - - // Set a timeout (300 seconds = 5 minutes) - builder = builder.setTimeout(300); - - // Build the transaction - const transaction = builder.build(); - - return { - xdr: transaction.toXDR(), - transaction, - }; -} - -/** - * Parse and validate a Stellar transaction XDR - */ -export function parseTransactionXDR(xdr: string): TransactionBuilder { - try { - return TransactionBuilder.fromXDR(xdr, Networks.TESTNET); - } catch (error) { - throw new Error("Invalid transaction XDR"); - } -} - -/** - * Get the appropriate network passphrase based on network type - */ -export function getNetworkPassphrase(network: "TESTNET" | "PUBLIC"): string { - return network === "TESTNET" - ? Networks.TESTNET - : Networks.PUBLIC; -} - -/** - * Validate that an amount is a valid Stellar amount format - */ -export function validateStellarAmount(amount: string): boolean { - const regex = /^\d+(\.\d{1,7})?$/; - return regex.test(amount) && parseFloat(amount) > 0; -} - -/** - * Convert a decimal amount to stroops (the smallest unit of XLM) - * 1 XLM = 10,000,000 stroops - */ -export function amountToStroops(amount: string): bigint { - const decimal = parseFloat(amount); - return BigInt(Math.floor(decimal * 10_000_000)); -} - -/** - * Convert stroops to decimal amount - */ -export function stroopsToAmount(stroops: bigint): string { - return (Number(stroops) / 10_000_000).toString(); -} +test \ No newline at end of file From 36cadc6be77b1ce9164433351510580648cb1fb8 Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:29 +0100 Subject: [PATCH 5/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- lib/hooks/use-freighter.ts | 226 +++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/lib/hooks/use-freighter.ts b/lib/hooks/use-freighter.ts index b2b9f07..2452ba8 100644 --- a/lib/hooks/use-freighter.ts +++ b/lib/hooks/use-freighter.ts @@ -1,5 +1,8 @@ 'use client' +import React, { useState, useCallback } from 'react' +import { truncateStellarAddress } from '@/components/wallet-provider' + export { useStellarWallet as useFreighter, truncateStellarAddress, @@ -9,3 +12,226 @@ export { } from '@/components/wallet-provider' export type { StellarNetwork } from '@/components/wallet-provider' + +// ----- Transaction Confirmation Modal ----- + +export type TransactionType = 'transfer' | 'contract-call' | 'group-action' | (string & {}) + +export interface TransactionDetails { + type: TransactionType + amount: string + tokenSymbol: string + walletAddress: string + network: 'Mainnet' | 'Testnet' + estimatedFee: string +} + +export type TransactionStatus = 'pending' | 'success' | 'failed' + +interface TransactionConfirmationModalProps { + open: boolean + details?: TransactionDetails + status: TransactionStatus + transactionHash?: string + error?: string + explorerUrl?: string + onClose: () => void + onRetry: () => void +} + +const TransactionConfirmationModal: React.FC = (props) => { + if (!props.open || !props.details) return null + + const [copied, setCopied] = useState(false) + + const handleCopyHash = async () => { + if (!props.transactionHash) return + try { + await navigator.clipboard.writeText(props.transactionHash) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + window.prompt('Copy transaction hash:', props.transactionHash) + } + } + + const statusColors: Record = { + pending: 'bg-yellow-100 text-yellow-800 border-yellow-300', + success: 'bg-green-100 text-green-800 border-green-300', + failed: 'bg-red-100 text-red-800 border-red-300', + } + + const statusLabels: Record = { + pending: 'Pending', + success: 'Success', + failed: 'Failed', + } + + const { details } = props + const status = props.status + + const statusIndicator = React.createElement( + 'div', + { className: `mb-4 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${statusColors[status]}` }, + status === 'pending' + ? React.createElement('svg', { className: 'mr-1 h-4 w-4 animate-spin', viewBox: '0 0 24 24', fill: 'none' }, + React.createElement('circle', { className: 'opacity-25', cx: '12', cy: '12', r: '10', stroke: 'currentColor', strokeWidth: '4' }), + React.createElement('path', { className: 'opacity-75', fill: 'currentColor', d: 'M4 12a8 8 0 018-8v8h4a4 4 0 01-4 4v4a8 8 0 01-8-8z' }) + ) + : status === 'success' + ? React.createElement('svg', { className: 'mr-1 h-4 w-4', fill: 'currentColor', viewBox: '0 0 20 20' }, + React.createElement('path', { fillRule: 'evenodd', d: 'M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z', clipRule: 'evenodd' }) + ) + : React.createElement('svg', { className: 'mr-1 h-4 w-4', fill: 'currentColor', viewBox: '0 0 20 20' }, + React.createElement('path', { fillRule: 'evenodd', d: 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z', clipRule: 'evenodd' }) + ), + ` ${statusLabels[status]}` + ) + + const detailRows: React.ReactNode[] = [ + React.createElement('div', { className: 'flex justify-between' }, + React.createElement('dt', { className: 'text-gray-500' }, 'Type'), + React.createElement('dd', { className: 'font-medium text-gray-900 capitalize' }, String(details.type)) + ), + React.createElement('div', { className: 'flex justify-between' }, + React.createElement('dt', { className: 'text-gray-500' }, 'Amount'), + React.createElement('dd', { className: 'font-medium text-gray-900' }, `${details.amount} ${details.tokenSymbol}`) + ), + React.createElement('div', { className: 'flex justify-between' }, + React.createElement('dt', { className: 'text-gray-500' }, 'Wallet'), + React.createElement('dd', { className: 'font-medium text-gray-900' }, truncateStellarAddress(details.walletAddress)) + ), + React.createElement('div', { className: 'flex justify-between' }, + React.createElement('dt', { className: 'text-gray-500' }, 'Network'), + React.createElement('dd', { className: 'font-medium text-gray-900' }, details.network) + ), + React.createElement('div', { className: 'flex justify-between' }, + React.createElement('dt', { className: 'text-gray-500' }, 'Estimated Fee'), + React.createElement('dd', { className: 'font-medium text-gray-900' }, details.estimatedFee) + ), + ] + + let transactionHashSection: React.ReactNode = null + if (status === 'success' && props.transactionHash) { + transactionHashSection = React.createElement('div', { className: 'mt-4 rounded bg-gray-50 p-3' }, + React.createElement('div', { className: 'flex items-center justify-between' }, + React.createElement('span', { className: 'text-xs font-medium text-gray-500' }, 'Transaction Hash'), + React.createElement('button', { onClick: handleCopyHash, className: 'inline-flex items-center text-xs font-medium text-indigo-600 hover:text-indigo-500' }, + copied ? 'Copied!' : 'Copy' + ) + ), + React.createElement('code', { className: 'mt-1 block break-all text-xs text-gray-700' }, props.transactionHash), + props.explorerUrl ? React.createElement('a', { href: props.explorerUrl, target: '_blank', rel: 'noopener noreferrer', className: 'mt-2 inline-block text-xs font-medium text-indigo-600 hover:text-indigo-500' }, + 'View on Explorer ↕' + ) : null + ) + } + + let errorSection: React.ReactNode = null + if (status === 'failed' && props.error) { + errorSection = React.createElement('div', { className: 'mt-4 rounded bg-red-50 p-3' }, + React.createElement('p', { className: 'text-sm text-red-700' }, props.error) + ) + } + + let actionButtons: React.ReactNode[] = [] + if (status === 'failed') { + actionButtons.push( + React.createElement('button', { key: 'retry', onClick: props.onRetry, className: 'rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700' }, 'Retry') + ) + } + actionButtons.push( + React.createElement('button', { key: 'close', onClick: props.onClose, className: 'rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200' }, 'Close') + ) + + return React.createElement('div', { className: 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4' }, + React.createElement('div', { className: 'w-full max-w-md rounded-lg bg-white shadow-xl' }, + React.createElement('div', { className: 'flex items-center justify-between border-b border-gray-200 px-4 py-3' }, + React.createElement('h2', { className: 'text-lg font-medium text-gray-900' }, 'Transaction Confirmation'), + React.createElement('button', { onClick: props.onClose, className: 'text-gray-500 hover:text-gray-700 focus:outline-none', 'aria-label': 'Close' }, + React.createElement('svg', { className: 'h-5 w-5', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, + React.createElement('path', { strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: '2', d: 'M6 18L18 6M6 6l12 12' }) + ) + ) + ), + React.createElement('div', { className: 'px-4 py-4' }, + statusIndicator, + React.createElement('dl', { className: 'space-y3 text-sm' }, ...detailRows), + transactionHashSection, + errorSection, + React.createElement('div', { className: 'mt-6 flex justify-end space-x3' }, ...actionButtons) + ) + ) + ) +} + +interface UseTransactionConfirmationOptions { + onRetry?: () => void +} + +interface UseTransactionConfirmationReturn { + modal: React.ReactElement + openModal: (details: TransactionDetails, options?: UseTransactionConfirmationOptions) => void + closeModal: () => void + updateStatus: (status: TransactionStatus, options?: { transactionHash?: string; error?: string; explorerUrl?: string }) => void +} + +function useTransactionConfirmation(): UseTransactionConfirmationReturn { + const [isOpen, setIsOpen] = useState(false) + const [details, setDetails] = useState(undefined) + const [status, setStatus] = useState(undefined) + const [error, setError] = useState(undefined) + const [explorerUrl, setExplorerUrl] = useState(undefined) + const [onRetry, setOnRetry] = useState<(() => void) | undefined>(undefined) + + const openModal = useCallback((newDetails: TransactionDetails, options?: UseTransactionConfirmationOptions) => { + setDetails(newDetails) + setStatus('pending') + setTransactionHash(undefined) + setError(undefined) + setExplorerUrl(undefined) + setOnRetry(options?.onRetry) + setIsOpen(true) + }, []) + + const closeModal = useCallback(() => { + setIsOpen(false) + setDetails(undefined) + setOnRetry(undefined) + }, []) + + const updateStatus = useCallback( + (newStatus: TransactionStatus, options?: { transactionHash?: string; error?: string; explorerUrl?: string }) => { + setStatus(newStatus) + if (options) { + if (options.transactionHash) setTransactionHash(options.transactionHash) + if (options.error) setError(options.error) + if (options.explorerUrl) setExplorerUrl(options.explorerUrl) + } + }, + [] + ) + + const modal = React.createElement(TransactionConfirmationModal, { + open: isOpen, + details: details, + status: status, + transactionHash: transactionHash, + error: error, + explorerUrl: explorerUrl, + onClose: closeModal, + onRetry: () => { + if (onRetry) onRetry() + setStatus('pending') + setError(undefined) + setTransactionHash(undefined) + setExplorerUrl(undefined) + }, + }) + + return { modal, openModal, closeModal, updateStatus } +} + +export { TransactionConfirmationModal, useTransactionConfirmation } +export type { TransactionDetails, TransactionStatus, UseTransactionConfirmationReturn } \ No newline at end of file From c361e89d534e1a98e0e9a50f891203da8bbebbdb Mon Sep 17 00:00:00 2001 From: Precious Duyilemi Date: Tue, 25 Aug 2026 18:33:31 +0100 Subject: [PATCH 6/6] feat: [Feature]: Wallet Transaction Confirmation Modal (#184) --- lib/stellar/explorer.ts | 138 ++++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 19 deletions(-) diff --git a/lib/stellar/explorer.ts b/lib/stellar/explorer.ts index 874fef1..03af99a 100644 --- a/lib/stellar/explorer.ts +++ b/lib/stellar/explorer.ts @@ -9,19 +9,19 @@ import type { StellarNetwork } from '@/components/wallet-provider' -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // Constants -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- -const EXPLORER_BASE_URLS: Record = { +const EXPL1ORER_BASE_URLS: Record = { TESTNET: 'https://stellar.expert/explorer/testnet', PUBLIC: 'https://stellar.expert/explorer/public', UNKNOWN: 'https://stellar.expert/explorer/testnet', // Default to testnet -} +'} -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // Types -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- export interface ExplorerLinkConfig { /** The network environment (testnet, public, or unknown) */ @@ -32,15 +32,15 @@ export interface ExplorerLinkConfig { className?: string } -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // URL Generators -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- /** * Get the base explorer URL for the given network. */ export function getExplorerBaseUrl(network: StellarNetwork): string { - return EXPLORER_BASE_URLS[network] || EXPLORER_BASE_URLS.TESTNET + return EXPLORER_BASE_URLS[network] || EXPLorER_BASE_URLS.TESTNET } /** @@ -108,19 +108,19 @@ export function getAssetUrl( return `${base}/asset/${assetCode}-${assetIssuer}` } -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // Formatting Helpers -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- /** * Truncate a hash or address for display purposes. * * @param hash - The full hash or address string. * @param chars - Number of characters to keep at the start and end (default: 4). - * @returns The truncated string, e.g. "abcde...xyzw" + * @returns The truncated string, e.g. "abcde...xyzw * * @example - * truncateHash('GABCDEF1234567890XYZ') // => 'GABC...XYZ0' + * truncateHash('GABCDEF1234567890XYZ') // => 'GABC...XZZ0' * truncateHash('abc123def456', 3) // => 'abc...456' */ export function truncateHash(hash: string, chars = 4): string { @@ -156,7 +156,7 @@ export function isTransactionHash(hash: string): boolean { * Stellar account addresses are 56 characters starting with G or C. */ export function isStellarAddress(address: string): boolean { - return /^[GC][1-9A-HJ-NP-Za-km-z]{55}$/.test(address) + return /^[GC][1-9A-HJN-PZ-a-km-z]{55}$/.test(address) } /** @@ -164,12 +164,12 @@ export function isStellarAddress(address: string): boolean { * Contract IDs are typically 56 characters starting with C. */ export function isContractId(contractId: string): boolean { - return /^C[1-9A-HJ-NP-Za-km-z]{55}$/.test(contractId) + return /^C[1-9A-HJ-NP-Z-a-km-z]{55}$/.test(contractId) } -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // Clipboard Helper -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- /** * Copy the given text to the clipboard. @@ -200,9 +200,9 @@ export async function copyToClipboard(text: string): Promise { } } -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- // Network Helpers -// --------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------- /** * Get a human-readable label for the network type. @@ -252,3 +252,103 @@ export function networkFromPassphrase(passphrase: string | null | undefined): St if (!passphrase) return 'UNKNOWN' return NETWORK_PASSPHRASES[passphrase] || 'UNKNOWN' } + +// -------------------------------------------------------------------------------------- +// Transaction Confirmation Helpers +// -------------------------------------------------------------------------------------- + +/** + * The status of a transaction as displayed in the confirmation modal. + */ +export type TransactionStatus = 'success' | 'failed' | 'pending' + +/** + * The type of blockchain operation. + */ +export type TransactionType = 'transfer' | 'contract_call' | 'group_action' | 'other' + +/** + * Format a transaction amount with currency/token symbol. + * + * @param amount - The amount as a string or number. + * @param assetCode - The asset code (e.g., "XLM", "USDC"). + * @param decimals - Maximum number of decimal places to display (default: 7). + * @returns Formatted amount string, e.g. "123.456 XLM". + * + * @example + * formatTransactionAmount('100.5', 'XLM') // => "100.5 XLM" + * formatTransactionAmount(0.00001, 'BTC', 8) // => "0.00001 BTC" + */ +export function formatTransactionAmount( + amount: string | number, + assetCode: string, + decimals = 7 +}: string { + const numericValue = typeof amount === 'string' ? parseFloat(amount) : amount + if (Number.isNaN(numericValue)) return `0 ${assetCode}` + const formatted = numericValue.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }) + return `${formatted} ${assetCode}` +} + +/** + * Get a human-readable label for a transaction status. + * + * @param status - The transaction status ("pending", "success", or "failed"). + * @returns "Pending", "Success", or "Failed". + * + * @example + * getTransactionStatusLabel('pending') // => "Pending" + */ +export function getTransactionStatusLabel(status: TransactionStatus): string { + switch (status) { + case 'pending': + return 'Pending' + case 'success': + return 'Success' + case 'failed': + return 'Failed' + default: + return status + } +} + +/** + * Get a human-readable label for a transaction type. + * + * @param type - The transaction type ("transfer", "contract_call", "group_action", or "other"). + * @returns "Transfer", "Contract Call", "Group Action", or "Other". + * + * @example + * getTransactionTypeLabel('transfer') // => "Transfer" + */ +export function getTransactionTypeLabel(type: TransactionType): string { + switch (type) { + case 'transfer': + return 'Transfer' + case 'contract_call': + return 'Contract Call' + case 'group_action': + return 'Group Action' + default: + return 'Other' + } +} + +/** + * Format a Stellar transaction fee from stroops to a human-readable XLM amount. + * + * @param feeInStroops - The fee in stroops (1 XLM = 10,000,000 stroops). + * @returns A formatted fee string, e.g. "0.00001 XLM". + * + * @example + * formatTransactionFee(100) // => "0.00001 XLM" + */ +export function formatTransactionFee(feeInStroops: number | string): string { + const fee = typeof feeInStroops === 'string' ? parseFloat(feeInStroops) : feeInStroops + if (Number.isNaN(fee)) return '0 XLM' + const xlm = fee / 10_000_000 + return `${xlm.toLocaleString(undefined, { maximumFractionDigits: 7 })} XLM` +} \ No newline at end of file