1- import { Refresh01Icon } from "@hugeicons/core-free-icons" ;
2- import { HugeiconsIcon } from "@hugeicons/react" ;
3- import { useCallback , useEffect , useState } from "react" ;
4-
1+ import { useEffect , useState , useRef , useCallback } from "react" ;
52import { getClient } from "@/lib/client" ;
63import { cn } from "@/lib/utils" ;
4+ import { HugeiconsIcon } from "@hugeicons/react" ;
5+ import { Refresh01Icon } from "@hugeicons/core-free-icons" ;
76
8- interface FeeData {
9- baseFee : string ;
10- recommended : string ;
11- }
12-
13- interface FeeEstimatorProps {
7+ export interface FeeEstimatorProps {
8+ operations ?: number ;
9+ network ?: "testnet" | "public" ;
10+ onEstimate ?: ( fee : string ) => void ;
1411 className ?: string ;
15- /** Auto-refresh interval in ms. 0 = no refresh. */
1612 refreshInterval ?: number ;
1713}
1814
1915export function FeeEstimator ( {
16+ operations = 1 ,
17+ network : _network = "testnet" ,
18+ onEstimate,
2019 className,
21- refreshInterval = 0 ,
20+ refreshInterval = 10000 ,
2221} : FeeEstimatorProps ) {
23- const [ fee , setFee ] = useState < FeeData | null > ( null ) ;
24- const [ loading , setLoading ] = useState ( true ) ;
22+ const [ fee , setFee ] = useState < { baseFee : string ; recommended : string } | null > ( null ) ;
23+ const [ loading , setLoading ] = useState ( false ) ;
2524 const [ error , setError ] = useState < string | null > ( null ) ;
25+ const [ retryTrigger , setRetryTrigger ] = useState ( 0 ) ;
26+
27+ const loadingRef = useRef ( false ) ;
28+ const onEstimateRef = useRef ( onEstimate ) ;
29+
30+ // Update ref without triggering re-render
31+ useEffect ( ( ) => {
32+ onEstimateRef . current = onEstimate ;
33+ } ) ;
2634
2735 const load = useCallback ( async ( ) => {
36+ if ( loadingRef . current ) return ;
37+ loadingRef . current = true ;
2838 setLoading ( true ) ;
39+ setError ( null ) ;
2940 try {
3041 const { data, error : err } = await getClient ( ) . transaction . estimateFee ( ) ;
3142 if ( err ) {
@@ -34,33 +45,62 @@ export function FeeEstimator({
3445 }
3546 setFee ( data ) ;
3647 setError ( null ) ;
48+ } catch ( e ) {
49+ setError ( e instanceof Error ? e . message : "Failed to load fee estimate" ) ;
3750 } finally {
3851 setLoading ( false ) ;
52+ loadingRef . current = false ;
3953 }
4054 } , [ ] ) ;
4155
56+ // Initial load and retry trigger
4257 useEffect ( ( ) => {
43- const timerId = window . setTimeout ( ( ) => {
58+ let cancelled = false ;
59+ const doLoad = async ( ) => {
60+ if ( ! cancelled ) {
61+ await load ( ) ;
62+ }
63+ } ;
64+ void doLoad ( ) ;
65+ return ( ) => {
66+ cancelled = true ;
67+ } ;
68+ } , [ load , retryTrigger ] ) ;
69+
70+ // Interval manager
71+ useEffect ( ( ) => {
72+ if ( refreshInterval <= 0 || error ) return ;
73+
74+ const intervalId = setInterval ( ( ) => {
4475 void load ( ) ;
45- } , 0 ) ;
46- if ( refreshInterval > 0 ) {
47- const id = setInterval ( ( ) => {
48- void load ( ) ;
49- } , refreshInterval ) ;
50- return ( ) => {
51- window . clearTimeout ( timerId ) ;
52- clearInterval ( id ) ;
53- } ;
54- }
76+ } , refreshInterval ) ;
77+
5578 return ( ) => {
56- window . clearTimeout ( timerId ) ;
79+ clearInterval ( intervalId ) ;
5780 } ;
58- } , [ load , refreshInterval ] ) ;
81+ } , [ load , refreshInterval , error ] ) ;
82+
83+ // Handle onEstimate callback when recommended fee or operations changes
84+ useEffect ( ( ) => {
85+ if ( fee ?. recommended ) {
86+ const displayRecommended = ( parseInt ( fee . recommended ) * operations ) . toString ( ) ;
87+ onEstimateRef . current ?.( displayRecommended ) ;
88+ }
89+ } , [ fee ?. recommended , operations ] ) ;
90+
91+ // Trigger manually or via retry
92+ const handleRetry = useCallback ( ( ) => {
93+ setRetryTrigger ( ( prev ) => prev + 1 ) ;
94+ } , [ ] ) ;
95+
96+ // Calculate fees to display
97+ const displayBaseFee = fee ? ( parseInt ( fee . baseFee ) * operations ) . toString ( ) : "" ;
98+ const displayRecommended = fee ? ( parseInt ( fee . recommended ) * operations ) . toString ( ) : "" ;
5999
60100 return (
61101 < div
62102 className = { cn (
63- "rounded-xl border border-line bg-surface overflow-hidden" ,
103+ "rounded-xl border border-line bg-surface overflow-hidden relative " ,
64104 className ,
65105 ) }
66106 >
@@ -72,11 +112,11 @@ export function FeeEstimator({
72112 </ p >
73113 </ div >
74114 < button
75- onClick = { ( ) => void load ( ) }
115+ onClick = { handleRetry }
76116 disabled = { loading }
117+ aria-label = "Refresh fee estimate"
77118 className = "p-1.5 rounded-lg hover:bg-surface-2 text-ink-3 hover:text-ink-2 transition-colors disabled:opacity-40"
78119 title = "Refresh"
79- aria-label = "Refresh fee estimate"
80120 >
81121 < HugeiconsIcon
82122 icon = { Refresh01Icon }
@@ -88,21 +128,40 @@ export function FeeEstimator({
88128 </ button >
89129 </ div >
90130
91- < div className = "px-5 py-4" aria-live = "polite" aria-atomic = "true" >
92- { loading && ! fee ? (
93- < div className = "flex gap-4" >
94- < div className = "h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
95- < div className = "h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
131+ < div className = "px-5 py-4 min-h-[64px] flex items-center" >
132+ { /* Polite live region for screen readers to announce fee updates */ }
133+ < div aria-live = "polite" aria-atomic = "true" className = "sr-only" >
134+ { fee ? `Base Fee: ${ displayBaseFee } stroops, Recommended: ${ displayRecommended } stroops` : "" }
135+ </ div >
136+
137+ { loading && ! fee && ! error ? (
138+ < div className = "flex gap-4 animate-pulse w-full" >
139+ < div className = "h-8 w-24 rounded-lg bg-surface-2" />
140+ < div className = "h-8 w-24 rounded-lg bg-surface-2" />
96141 </ div >
97142 ) : error ? (
98- < p className = "text-[12px] text-red" > { error } </ p >
143+ < div className = "flex flex-col gap-2 items-start w-full" >
144+ < p className = "text-[12px] text-red" > { error } </ p >
145+ < button
146+ type = "button"
147+ onClick = { handleRetry }
148+ className = "px-3 py-1.5 text-[11px] font-medium text-white bg-brand rounded-lg hover:bg-brand-hover transition-colors"
149+ >
150+ Retry
151+ </ button >
152+ </ div >
99153 ) : fee ? (
100- < div className = "flex items-center gap-4" >
101- < FeeCell label = "Base Fee" value = { fee . baseFee } unit = "stroops" />
154+ < div className = "flex items-center gap-4 relative w-full" >
155+ { loading && (
156+ < div className = "absolute inset-0 flex items-center justify-center bg-surface/50 backdrop-blur-[0.5px]" >
157+ < span className = "w-4 h-4 border border-current border-t-transparent rounded-full animate-spin text-brand" />
158+ </ div >
159+ ) }
160+ < FeeCell label = "Base Fee" value = { displayBaseFee } unit = "stroops" />
102161 < div className = "w-px h-8 bg-line" />
103162 < FeeCell
104163 label = "Recommended"
105- value = { fee . recommended }
164+ value = { displayRecommended }
106165 unit = "stroops"
107166 highlight
108167 />
@@ -113,17 +172,19 @@ export function FeeEstimator({
113172 ) ;
114173}
115174
175+ interface FeeCellProps {
176+ label : string ;
177+ value : string ;
178+ unit : string ;
179+ highlight ?: boolean ;
180+ }
181+
116182function FeeCell ( {
117183 label,
118184 value,
119185 unit,
120186 highlight,
121- } : {
122- label : string ;
123- value : string ;
124- unit : string ;
125- highlight ?: boolean ;
126- } ) {
187+ } : FeeCellProps ) {
127188 return (
128189 < div className = "flex flex-col gap-1" >
129190 < span className = "text-[10px] font-semibold uppercase tracking-[0.1em] text-ink-4" >
0 commit comments