1+ "use client" ;
2+
3+ import React , { useState , useCallback } from "react" ;
4+ import { motion , AnimatePresence } from "framer-motion" ;
5+ import type { StreamData } from "@/components/view-stream-client" ;
6+
7+ interface ActionButtonsProps {
8+ stream : StreamData ;
9+ }
10+
11+ type ActionType = "pause" | "resume" | "cancel" | "withdraw" | "topup" ;
12+
13+ interface ActionModal {
14+ type : ActionType ;
15+ gasEstimate : string ;
16+ description : string ;
17+ confirmLabel : string ;
18+ danger ?: boolean ;
19+ }
20+
21+ const ACTION_MODALS : Record < ActionType , ActionModal > = {
22+ pause : {
23+ type : "pause" ,
24+ gasEstimate : "0.0012 XLM" ,
25+ description : "Pause the stream. Funds will stop flowing until resumed." ,
26+ confirmLabel : "Pause Stream" ,
27+ } ,
28+ resume : {
29+ type : "resume" ,
30+ gasEstimate : "0.0011 XLM" ,
31+ description : "Resume the stream. Funds will continue flowing." ,
32+ confirmLabel : "Resume Stream" ,
33+ } ,
34+ cancel : {
35+ type : "cancel" ,
36+ gasEstimate : "0.0015 XLM" ,
37+ description :
38+ "Cancel the stream permanently. Unstreamed funds return to sender. This action cannot be undone." ,
39+ confirmLabel : "Cancel Stream" ,
40+ danger : true ,
41+ } ,
42+ withdraw : {
43+ type : "withdraw" ,
44+ gasEstimate : "0.0013 XLM" ,
45+ description :
46+ "Withdraw streamed funds to your wallet." ,
47+ confirmLabel : "Withdraw Funds" ,
48+ } ,
49+ topup : {
50+ type : "topup" ,
51+ gasEstimate : "0.0010 XLM" ,
52+ description :
53+ "Add more funds to the stream. Extends the stream duration." ,
54+ confirmLabel : "Add Funds" ,
55+ } ,
56+ } ;
57+
58+ /**
59+ * Action Buttons — quick action buttons with hover effects, ripple animation,
60+ * and modals showing gas estimates for each operation.
61+ */
62+ export function ActionButtons ( { stream } : ActionButtonsProps ) {
63+ const [ activeModal , setActiveModal ] = useState < ActionType | null > ( null ) ;
64+ const [ loadingAction , setLoadingAction ] = useState < string | null > ( null ) ;
65+ const [ ripple , setRipple ] = useState < { x : number ; y : number ; id : string } | null > ( null ) ;
66+
67+ const isActive = stream . status === "active" ;
68+ const isPaused = stream . status === "paused" ;
69+
70+ const actions : { type : ActionType ; label : string ; icon : React . ReactNode ; available : boolean } [ ] = [
71+ {
72+ type : "pause" ,
73+ label : "Pause" ,
74+ icon : (
75+ < svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
76+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
77+ </ svg >
78+ ) ,
79+ available : isActive ,
80+ } ,
81+ {
82+ type : "resume" ,
83+ label : "Resume" ,
84+ icon : (
85+ < svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
86+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
87+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
88+ </ svg >
89+ ) ,
90+ available : isPaused ,
91+ } ,
92+ {
93+ type : "withdraw" ,
94+ label : "Withdraw" ,
95+ icon : (
96+ < svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
97+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
98+ </ svg >
99+ ) ,
100+ available : true ,
101+ } ,
102+ {
103+ type : "topup" ,
104+ label : "Top Up" ,
105+ icon : (
106+ < svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
107+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M12 6v6m0 0v6m0-6h6m-6 0H6" />
108+ </ svg >
109+ ) ,
110+ available : isActive || isPaused ,
111+ } ,
112+ {
113+ type : "cancel" ,
114+ label : "Cancel" ,
115+ icon : (
116+ < svg className = "h-4 w-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
117+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
118+ </ svg >
119+ ) ,
120+ available : isActive || isPaused ,
121+ } ,
122+ ] ;
123+
124+ const handleClick = useCallback (
125+ ( e : React . MouseEvent < HTMLButtonElement > , type : ActionType ) => {
126+ // Ripple effect
127+ const rect = e . currentTarget . getBoundingClientRect ( ) ;
128+ const x = e . clientX - rect . left ;
129+ const y = e . clientY - rect . top ;
130+ setRipple ( { x, y, id : type } ) ;
131+ setTimeout ( ( ) => setRipple ( null ) , 600 ) ;
132+
133+ // Open modal
134+ setActiveModal ( type ) ;
135+ } ,
136+ [ ] ,
137+ ) ;
138+
139+ const handleConfirm = useCallback ( async ( ) => {
140+ if ( ! activeModal ) return ;
141+ setLoadingAction ( activeModal ) ;
142+ // Simulate transaction
143+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
144+ setLoadingAction ( null ) ;
145+ setActiveModal ( null ) ;
146+ } , [ activeModal ] ) ;
147+
148+ return (
149+ < >
150+ { /* Action buttons */ }
151+ < motion . div
152+ initial = { { opacity : 0 , y : 12 } }
153+ animate = { { opacity : 1 , y : 0 } }
154+ transition = { { duration : 0.6 , delay : 0.35 , ease : "easeOut" } }
155+ className = "rounded-2xl border border-white/10 bg-white/[0.03] p-5 backdrop-blur-xl"
156+ >
157+ < p className = "mb-4 font-body text-[10px] uppercase tracking-widest text-white/35" >
158+ Quick Actions
159+ </ p >
160+
161+ < div className = "grid grid-cols-2 gap-3 sm:grid-cols-5" >
162+ { actions . map ( ( action ) => (
163+ < button
164+ key = { action . type }
165+ onClick = { ( e ) => handleClick ( e , action . type ) }
166+ disabled = { ! action . available }
167+ className = "group relative overflow-hidden rounded-xl border border-white/10 bg-white/[0.02] p-3 text-center transition-all duration-200 hover:border-white/20 hover:bg-white/[0.05] disabled:cursor-not-allowed disabled:opacity-30"
168+ aria-label = { `${ action . label } stream` }
169+ >
170+ { /* Ripple layer */ }
171+ { ripple ?. id === action . type && (
172+ < span
173+ className = "pointer-events-none absolute h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/10"
174+ style = { {
175+ left : ripple . x ,
176+ top : ripple . y ,
177+ animation : "ripple-anim 0.6s ease-out forwards" ,
178+ } }
179+ />
180+ ) }
181+
182+ { /* Icon */ }
183+ < div className = "mb-1.5 flex justify-center text-white/40 transition-colors group-hover:text-[#00f5ff] group-hover:drop-shadow-[0_0_6px_rgba(0,245,255,0.3)]" >
184+ { action . icon }
185+ </ div >
186+
187+ { /* Label */ }
188+ < span className = "font-body text-[10px] font-medium uppercase tracking-wider text-white/40 transition-colors group-hover:text-white/70" >
189+ { action . label }
190+ </ span >
191+
192+ { /* Glow border on hover */ }
193+ < div className = "pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
194+ style = { {
195+ boxShadow : "inset 0 0 20px rgba(0,245,255,0.05)" ,
196+ } }
197+ />
198+ </ button >
199+ ) ) }
200+ </ div >
201+ </ motion . div >
202+
203+ { /* Modal */ }
204+ < AnimatePresence >
205+ { activeModal && (
206+ < motion . div
207+ initial = { { opacity : 0 } }
208+ animate = { { opacity : 1 } }
209+ exit = { { opacity : 0 } }
210+ className = "fixed inset-0 z-50 flex items-center justify-center p-4"
211+ onClick = { ( ) => setActiveModal ( null ) }
212+ >
213+ { /* Backdrop */ }
214+ < div className = "absolute inset-0 bg-black/60 backdrop-blur-sm" />
215+
216+ { /* Modal content */ }
217+ < motion . div
218+ initial = { { opacity : 0 , scale : 0.95 , y : 10 } }
219+ animate = { { opacity : 1 , scale : 1 , y : 0 } }
220+ exit = { { opacity : 0 , scale : 0.95 , y : 10 } }
221+ transition = { { duration : 0.2 , ease : "easeOut" } }
222+ onClick = { ( e ) => e . stopPropagation ( ) }
223+ className = "relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0a0a14] p-6 shadow-2xl"
224+ >
225+ { /* Close button */ }
226+ < button
227+ onClick = { ( ) => setActiveModal ( null ) }
228+ className = "absolute right-4 top-4 text-white/30 transition-colors hover:text-white/60"
229+ aria-label = "Close modal"
230+ >
231+ < svg className = "h-5 w-5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
232+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M6 18L18 6M6 6l12 12" />
233+ </ svg >
234+ </ button >
235+
236+ { /* Modal icon */ }
237+ < div
238+ className = { `mb-4 flex h-12 w-12 items-center justify-center rounded-xl ${
239+ ACTION_MODALS [ activeModal ] . danger
240+ ? "bg-red-500/10 text-red-400"
241+ : "bg-[#00f5ff]/10 text-[#00f5ff]"
242+ } `}
243+ >
244+ { actions . find ( ( a ) => a . type === activeModal ) ?. icon }
245+ </ div >
246+
247+ { /* Title */ }
248+ < h2 className = "font-heading text-lg font-bold text-white" >
249+ { ACTION_MODALS [ activeModal ] . confirmLabel }
250+ </ h2 >
251+
252+ { /* Description */ }
253+ < p className = "mt-2 font-body text-sm text-white/50" >
254+ { ACTION_MODALS [ activeModal ] . description }
255+ </ p >
256+
257+ { /* Stream info */ }
258+ < div className = "mt-4 rounded-xl border border-white/5 bg-white/[0.02] p-3" >
259+ < div className = "flex items-center justify-between text-sm" >
260+ < span className = "font-body text-white/40" > Stream</ span >
261+ < span className = "font-ticker text-white/70" > { stream . name } </ span >
262+ </ div >
263+ </ div >
264+
265+ { /* Gas estimate */ }
266+ < div className = "mt-3 flex items-center justify-between rounded-xl border border-amber-500/20 bg-amber-500/5 p-3" >
267+ < div className = "flex items-center gap-2" >
268+ < svg className = "h-4 w-4 text-amber-400" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
269+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M13 10V3L4 14h7v7l9-11h-7z" />
270+ </ svg >
271+ < span className = "font-body text-xs text-white/50" > Estimated Gas Fee</ span >
272+ </ div >
273+ < span className = "font-ticker text-sm font-semibold text-amber-400" >
274+ { ACTION_MODALS [ activeModal ] . gasEstimate }
275+ </ span >
276+ </ div >
277+
278+ { /* Action buttons */ }
279+ < div className = "mt-6 flex gap-3" >
280+ < button
281+ onClick = { ( ) => setActiveModal ( null ) }
282+ className = "flex-1 rounded-xl border border-white/10 py-2.5 font-body text-sm text-white/50 transition-colors hover:border-white/20 hover:text-white/70"
283+ >
284+ Cancel
285+ </ button >
286+ < button
287+ onClick = { handleConfirm }
288+ disabled = { loadingAction === activeModal }
289+ className = { `flex-1 rounded-xl py-2.5 font-body text-sm font-semibold transition-all ${
290+ ACTION_MODALS [ activeModal ] . danger
291+ ? "bg-red-500/80 text-white hover:bg-red-500"
292+ : "bg-[#00f5ff]/80 text-black hover:bg-[#00f5ff]"
293+ } disabled:cursor-not-allowed disabled:opacity-60`}
294+ >
295+ { loadingAction === activeModal ? (
296+ < span className = "flex items-center justify-center gap-2" >
297+ < svg className = "h-4 w-4 animate-spin" viewBox = "0 0 24 24" fill = "none" >
298+ < circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
299+ < path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
300+ </ svg >
301+ Processing...
302+ </ span >
303+ ) : (
304+ ACTION_MODALS [ activeModal ] . confirmLabel
305+ ) }
306+ </ button >
307+ </ div >
308+ </ motion . div >
309+ </ motion . div >
310+ ) }
311+ </ AnimatePresence >
312+
313+ { /* Ripple animation keyframes injected once */ }
314+ < style > { `
315+ @keyframes ripple-anim {
316+ 0% { transform: translate(-50%, -50%) scale(0); opacity: 0.5; }
317+ 100% { transform: translate(-50%, -50%) scale(4); opacity: 0; }
318+ }
319+ ` } </ style >
320+ </ >
321+ ) ;
322+ }
0 commit comments