11"use client" ;
22
3- import React , { useMemo , useState } from "react" ;
3+ import React , { useEffect , useMemo , useRef , useState } from "react" ;
4+ import { useRouter } from "next/navigation" ;
45import {
56 AlertTriangle ,
67 CheckCircle2 ,
@@ -13,6 +14,7 @@ import {
1314} from "lucide-react" ;
1415import { IEscrowExtended } from "@/types/escrow" ;
1516import TransactionTracker from "@/components/stellar/TransactionTracker" ;
17+ import { toast } from "sonner" ;
1618
1719type ReleaseMode = "manual" | "auto" ;
1820
@@ -28,9 +30,27 @@ interface ReleaseFundsModalProps {
2830}
2931
3032type Step = "review" | "confirm" | "success" ;
33+ type SigningPhase = "idle" | "building" | "waiting" | "submitting" | "confirming" | "complete" | "timeout" ;
3134
3235const PLATFORM_FEE_BPS = 50 ;
3336const BPS_DENOMINATOR = 10_000 ;
37+ const NETWORK_FEE = "0.00001 XLM" ;
38+ const SIGNING_TIMEOUT_MS = 60_000 ;
39+
40+ const getReleaseError = ( error : unknown ) : string => {
41+ const message = error instanceof Error ? error . message : String ( error ) ;
42+ const normalized = message . toLowerCase ( ) ;
43+ if ( normalized . includes ( "balance" ) || normalized . includes ( "underfunded" ) ) {
44+ return "Insufficient balance to release funds, including the network fee." ;
45+ }
46+ if ( normalized . includes ( "sequence" ) || normalized . includes ( "tx_bad_seq" ) ) {
47+ return "Your wallet sequence number is out of date. Refresh your wallet and try again." ;
48+ }
49+ if ( normalized . includes ( "fetch" ) || normalized . includes ( "network" ) ) {
50+ return "A network error prevented the transaction from being submitted." ;
51+ }
52+ return message || "Failed to release funds. Please try again." ;
53+ } ;
3454
3555export const ReleaseFundsModal : React . FC < ReleaseFundsModalProps > = ( {
3656 isOpen,
@@ -42,6 +62,7 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
4262 publicKey,
4363 network = "testnet" ,
4464} ) => {
65+ const router = useRouter ( ) ;
4566 const existingTxHash =
4667 ( escrow as any ) . releaseTransactionHash ??
4768 ( escrow as any ) . onChainReleaseHash ??
@@ -57,6 +78,8 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
5778 const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
5879 const [ error , setError ] = useState < string | null > ( null ) ;
5980 const [ txHash , setTxHash ] = useState < string | null > ( existingTxHash ?? null ) ;
81+ const [ signingPhase , setSigningPhase ] = useState < SigningPhase > ( "idle" ) ;
82+ const abortControllerRef = useRef < AbortController | null > ( null ) ;
6083
6184 const sellerAddress =
6285 escrow . counterpartyAddress || ( escrow as any ) . sellerAddress || "Unknown" ;
@@ -94,12 +117,33 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
94117 } , [ escrow . amount , escrow . asset ] ) ;
95118
96119 const handleClose = ( ) => {
120+ abortControllerRef . current ?. abort ( ) ;
97121 setError ( null ) ;
98122 setIsSubmitting ( false ) ;
123+ setSigningPhase ( "idle" ) ;
99124 setStep ( isAlreadyReleased ? "success" : "review" ) ;
100125 onClose ( ) ;
101126 } ;
102127
128+ useEffect ( ( ) => {
129+ if ( step !== "success" ) return ;
130+ const timeoutId = window . setTimeout ( ( ) => {
131+ onClose ( ) ;
132+ router . push ( `/escrow/${ escrow . id } ` ) ;
133+ } , 3_000 ) ;
134+ return ( ) => window . clearTimeout ( timeoutId ) ;
135+ } , [ escrow . id , onClose , router , step ] ) ;
136+
137+ const signingLabel = {
138+ idle : "" ,
139+ building : "Building Transaction" ,
140+ waiting : "Waiting for Wallet" ,
141+ submitting : "Submitting" ,
142+ confirming : "Confirming" ,
143+ complete : "Complete" ,
144+ timeout : "Timed Out" ,
145+ } [ signingPhase ] ;
146+
103147 const handlePrimaryAction = async ( ) => {
104148 if ( step === "review" ) {
105149 setStep ( "confirm" ) ;
@@ -114,13 +158,22 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
114158
115159 setIsSubmitting ( true ) ;
116160 setError ( null ) ;
161+ setSigningPhase ( "building" ) ;
162+ const abortController = new AbortController ( ) ;
163+ abortControllerRef . current = abortController ;
164+ const timeoutId = window . setTimeout ( ( ) => abortController . abort ( ) , SIGNING_TIMEOUT_MS ) ;
117165
118166 try {
167+ await Promise . resolve ( ) ;
168+ setSigningPhase ( "waiting" ) ;
169+ await Promise . resolve ( ) ;
170+ setSigningPhase ( "submitting" ) ;
119171 const response = await fetch ( `/api/escrows/${ escrow . id } /release` , {
120172 method : "POST" ,
121173 headers : {
122174 "Content-Type" : "application/json" ,
123175 } ,
176+ signal : abortController . signal ,
124177 } ) ;
125178
126179 if ( ! response . ok ) {
@@ -141,14 +194,25 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
141194 setTxHash ( data . transactionHash ) ;
142195 }
143196
197+ setSigningPhase ( "confirming" ) ;
198+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , 150 ) ) ;
144199 setStep ( "success" ) ;
200+ toast . success (
201+ data . transactionHash
202+ ? `Release confirmed: ${ data . transactionHash } `
203+ : "Escrow funds released successfully" ,
204+ ) ;
145205 } catch ( err ) {
206+ const timedOut = abortController . signal . aborted ;
207+ setSigningPhase ( timedOut ? "timeout" : "idle" ) ;
146208 setError (
147- err instanceof Error
148- ? err . message
149- : "Failed to release funds. Please try again." ,
209+ timedOut
210+ ? "Transaction timed out after 60 seconds. Cancel and try again."
211+ : getReleaseError ( err ) ,
150212 ) ;
151213 } finally {
214+ window . clearTimeout ( timeoutId ) ;
215+ abortControllerRef . current = null ;
152216 setIsSubmitting ( false ) ;
153217 }
154218 }
@@ -160,7 +224,8 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
160224 return "Done" ;
161225 } ) ( ) ;
162226
163- const primaryDisabled = isSubmitting ;
227+ const isSigning = isSubmitting || signingPhase === "timeout" ;
228+ const primaryDisabled = isSigning ;
164229
165230 const showTracker = step === "success" && txHash ;
166231
@@ -206,6 +271,26 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
206271 </ div >
207272 ) }
208273
274+ { isSubmitting && signingLabel && (
275+ < div
276+ className = "p-4 rounded-lg border border-blue-200 bg-blue-50"
277+ data-testid = "signing-status"
278+ >
279+ < div className = "flex items-center gap-3 text-blue-800 font-medium" >
280+ < Loader2 className = "w-5 h-5 animate-spin" />
281+ < span > { signingLabel } </ span >
282+ </ div >
283+ < div className = "mt-3 grid grid-cols-4 gap-1" aria-label = "Transaction progress" >
284+ { [ "building" , "waiting" , "submitting" , "confirming" ] . map ( ( phase ) => (
285+ < div
286+ key = { phase }
287+ className = { `h-1 rounded ${ phase === signingPhase ? "bg-blue-600" : "bg-blue-200" } ` }
288+ />
289+ ) ) }
290+ </ div >
291+ </ div >
292+ ) }
293+
209294 { ! isAlreadyReleased && step !== "success" && (
210295 < div
211296 className = { `p-4 rounded-lg border ${
@@ -315,6 +400,16 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
315400 </ div >
316401 </ div >
317402 ) }
403+
404+ { step === "confirm" && (
405+ < div className = "flex items-start space-x-3" >
406+ < Info className = "w-5 h-5 text-gray-500 flex-shrink-0 mt-0.5" />
407+ < div className = "flex-1" >
408+ < p className = "text-sm text-gray-500" > Estimated network fee</ p >
409+ < p className = "text-sm text-gray-900" > { NETWORK_FEE } </ p >
410+ </ div >
411+ </ div >
412+ ) }
318413 </ div >
319414 </ div >
320415
@@ -339,6 +434,14 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
339434 < span className = "font-medium" > Transaction hash:</ span > { " " }
340435 < span className = "font-mono break-all" > { txHash } </ span >
341436 </ p >
437+ < a
438+ href = { `https://stellar.expert/explorer/${ network } /tx/${ txHash } ` }
439+ target = "_blank"
440+ rel = "noreferrer"
441+ className = "text-sm text-emerald-700 underline"
442+ >
443+ View transaction on Stellar explorer
444+ </ a >
342445 < TransactionTracker
343446 txHash = { txHash }
344447 network = { network }
@@ -373,7 +476,7 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
373476 { isSubmitting ? (
374477 < span className = "flex items-center justify-center space-x-2" >
375478 < Loader2 className = "w-4 h-4 animate-spin" />
376- < span > Releasing...</ span >
479+ < span > { signingLabel ?? " Releasing..." } </ span >
377480 </ span >
378481 ) : (
379482 primaryLabel
0 commit comments