11"use client" ;
22
3- import { useEffect , useState , useCallback , useMemo , memo } from "react" ;
3+ import { useEffect , useState , useCallback , useMemo , memo , useRef } from "react" ;
44import Link from "next/link" ;
55import { useLocale } from "next-intl" ;
6+ import { toast , Toaster } from "sonner" ;
67import {
78 useHydrateMerchantStore ,
89 useMerchantApiKey ,
@@ -19,6 +20,7 @@ interface Payment {
1920 status : string ;
2021 description : string | null ;
2122 created_at : string ;
23+ isOptimistic ?: boolean ;
2224}
2325
2426interface PaymentRowProps {
@@ -42,7 +44,7 @@ const PaymentRow = memo(function PaymentRow({
4244 ( ) => formatAmount ( payment . amount , locale , hideCents ) ,
4345 [ payment . amount , locale , hideCents ] ,
4446 ) ;
45- const rowClassName = `group cursor-pointer outline-none transition-all duration-200 ease-in-out hover:bg-pluto-50 hover:shadow-sm active:bg-pluto-100 active:scale-[0.985] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-pluto-500 ${ index % 2 === 0 ? "bg-white" : "bg-[#F9F9F9]" } ` ;
47+ const rowClassName = `group cursor-pointer outline-none transition-all duration-200 ease-in-out hover:bg-pluto-50 hover:shadow-sm active:bg-pluto-100 active:scale-[0.985] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-pluto-500 ${ index % 2 === 0 ? "bg-white" : "bg-[#F9F9F9]" } ${ payment . isOptimistic ? "animate-pulse" : "" } ` ;
4648 const statusClassName = `inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-bold uppercase tracking-tight ${
4749 payment . status === "confirmed"
4850 ? "bg-[#0A0A0A] text-white"
@@ -55,11 +57,14 @@ const PaymentRow = memo(function PaymentRow({
5557 role = "row"
5658 tabIndex = { 0 }
5759 className = { rowClassName }
58- aria-label = { `Payment ${ payment . description || "Transaction" } for ${ formattedAmount } ${ payment . asset } ` }
60+ aria-label = { `Payment ${ payment . description || "Transaction" } for ${ formattedAmount } ${ payment . asset } ${ payment . isOptimistic ? " (Verifying)" : "" } ` }
5961 >
6062 < td className = "border-l-0 border-transparent px-6 py-4 transition-all duration-200 group-hover:border-l-2 group-hover:border-l-pluto-500" >
61- < div className = { statusClassName } aria-label = { `Status: ${ payment . status } ` } >
63+ < div className = { statusClassName } aria-label = { `Status: ${ payment . status } ${ payment . isOptimistic ? " (Verifying)" : "" } ` } >
6264 { payment . status }
65+ { payment . isOptimistic && (
66+ < span className = "h-1 w-1 rounded-full bg-current animate-pulse" />
67+ ) }
6368 </ div >
6469 </ td >
6570 < td className = "px-6 py-4" >
@@ -88,6 +93,7 @@ export default function ActivityFeed() {
8893 const [ payments , setPayments ] = useState < Payment [ ] > ( [ ] ) ;
8994 const [ loading , setLoading ] = useState ( true ) ;
9095 const [ error , setError ] = useState < string | null > ( null ) ;
96+ const verificationTimeouts = useRef < Map < string , NodeJS . Timeout > > ( new Map ( ) ) ;
9197
9298 const apiKey = useMerchantApiKey ( ) ;
9399 const hydrated = useMerchantHydrated ( ) ;
@@ -97,6 +103,53 @@ export default function ActivityFeed() {
97103
98104 useHydrateMerchantStore ( ) ;
99105
106+ // Verify optimistic payment with backend
107+ const verifyPayment = useCallback (
108+ async ( paymentId : string ) => {
109+ try {
110+ if ( ! apiKey ) return ;
111+
112+ const apiUrl =
113+ process . env . NEXT_PUBLIC_API_URL || "http://localhost:4000" ;
114+ const response = await fetch ( `${ apiUrl } /api/payments/${ paymentId } ` , {
115+ headers : { "x-api-key" : apiKey } ,
116+ signal : AbortSignal . timeout ( 5000 ) , // 5s timeout
117+ } ) ;
118+
119+ if ( ! response . ok ) {
120+ throw new Error ( "Verification failed" ) ;
121+ }
122+
123+ const data = await response . json ( ) ;
124+
125+ // Update payment to confirmed if verified
126+ setPayments ( ( prev ) =>
127+ prev . map ( ( p ) =>
128+ p . id === paymentId
129+ ? { ...p , status : data . status || "confirmed" , isOptimistic : false }
130+ : p ,
131+ ) ,
132+ ) ;
133+ } catch ( err ) {
134+ // Rollback on verification failure
135+ setPayments ( ( prev ) => {
136+ const payment = prev . find ( ( p ) => p . id === paymentId ) ;
137+ if ( payment ?. isOptimistic ) {
138+ toast . error ( "Payment verification failed" , {
139+ description : `${ formatAmount ( payment . amount , locale , hideCents ) } ${ payment . asset } - Network error, update rolled back` ,
140+ duration : 5000 ,
141+ } ) ;
142+ return prev . filter ( ( p ) => p . id !== paymentId ) ;
143+ }
144+ return prev ;
145+ } ) ;
146+ } finally {
147+ verificationTimeouts . current . delete ( paymentId ) ;
148+ }
149+ } ,
150+ [ apiKey , locale , hideCents ] ,
151+ ) ;
152+
100153 const handleConfirmed = useCallback (
101154 ( event : {
102155 id : string ;
@@ -111,21 +164,39 @@ export default function ActivityFeed() {
111164
112165 if ( exists ) {
113166 updatedList = prev . map ( ( p ) =>
114- p . id === event . id ? { ...p , status : "confirmed" } : p ,
167+ p . id === event . id ? { ...p , status : "confirmed" , isOptimistic : false } : p ,
115168 ) ;
169+ // Clear verification timeout if exists
170+ const timeout = verificationTimeouts . current . get ( event . id ) ;
171+ if ( timeout ) {
172+ clearTimeout ( timeout ) ;
173+ verificationTimeouts . current . delete ( event . id ) ;
174+ }
116175 } else {
117- // Brand new payment arrived confirmed
118- updatedList = [
119- {
120- id : event . id ,
121- amount : event . amount ,
122- asset : event . asset ,
123- status : "confirmed" ,
124- description : "Real-time payment" ,
125- created_at : event . confirmed_at ,
126- } ,
127- ...prev ,
128- ] ;
176+ // Brand new payment arrived - add optimistically
177+ const newPayment : Payment = {
178+ id : event . id ,
179+ amount : event . amount ,
180+ asset : event . asset ,
181+ status : "confirmed" ,
182+ description : "Real-time payment" ,
183+ created_at : event . confirmed_at ,
184+ isOptimistic : true ,
185+ } ;
186+
187+ updatedList = [ newPayment , ...prev ] ;
188+
189+ // Schedule verification in 3 seconds
190+ const timeout = setTimeout ( ( ) => {
191+ verifyPayment ( event . id ) ;
192+ } , 3000 ) ;
193+ verificationTimeouts . current . set ( event . id , timeout ) ;
194+
195+ // Show optimistic toast
196+ toast . success ( "New payment received" , {
197+ description : `${ formatAmount ( event . amount , locale , hideCents ) } ${ event . asset } ` ,
198+ duration : 3000 ,
199+ } ) ;
129200 }
130201
131202 // Sort to ensure highest items are top
@@ -138,9 +209,17 @@ export default function ActivityFeed() {
138209 . slice ( 0 , 10 ) ;
139210 } ) ;
140211 } ,
141- [ ] ,
212+ [ locale , hideCents , verifyPayment ] ,
142213 ) ;
143214
215+ // Cleanup verification timeouts on unmount
216+ useEffect ( ( ) => {
217+ return ( ) => {
218+ verificationTimeouts . current . forEach ( ( timeout ) => clearTimeout ( timeout ) ) ;
219+ verificationTimeouts . current . clear ( ) ;
220+ } ;
221+ } , [ ] ) ;
222+
144223 usePaymentSocket ( merchantId , handleConfirmed ) ;
145224
146225 useEffect ( ( ) => {
@@ -166,11 +245,26 @@ export default function ActivityFeed() {
166245
167246 const data = await response . json ( ) ;
168247 setPayments ( data . payments ?? [ ] ) ;
248+ setError ( null ) ;
169249 } catch ( err ) {
170250 if ( err instanceof Error && err . name === "AbortError" ) return ;
171- setError (
172- err instanceof Error ? err . message : "Failed to load activity" ,
173- ) ;
251+ const errorMessage =
252+ err instanceof Error ? err . message : "Failed to load activity" ;
253+ setError ( errorMessage ) ;
254+
255+ // Show toast for network failures
256+ toast . error ( "Network error" , {
257+ description : errorMessage ,
258+ action : {
259+ label : "Retry" ,
260+ onClick : ( ) => {
261+ setLoading ( true ) ;
262+ setError ( null ) ;
263+ fetchPayments ( ) ;
264+ } ,
265+ } ,
266+ duration : 10000 ,
267+ } ) ;
174268 } finally {
175269 setLoading ( false ) ;
176270 }
@@ -182,51 +276,62 @@ export default function ActivityFeed() {
182276
183277 if ( loading ) {
184278 return (
185- < div className = "space-y-4 animate-pulse" >
186- { [ ...Array ( 3 ) ] . map ( ( _ , i ) => (
187- < div key = { i } className = "h-14 w-full rounded-lg bg-[#F5F5F5] sm:h-16" />
188- ) ) }
189- </ div >
279+ < >
280+ < Toaster position = "top-right" richColors closeButton />
281+ < div className = "space-y-4 animate-pulse" >
282+ { [ ...Array ( 3 ) ] . map ( ( _ , i ) => (
283+ < div key = { i } className = "h-14 w-full rounded-lg bg-[#F5F5F5] sm:h-16" />
284+ ) ) }
285+ </ div >
286+ </ >
190287 ) ;
191288 }
192289
193290 if ( error ) {
194291 return (
195- < div className = "rounded-xl border border-red-500/30 p-4 text-red-400" >
196- { error }
197- </ div >
292+ < >
293+ < Toaster position = "top-right" richColors closeButton />
294+ < div className = "rounded-xl border border-red-500/30 p-4 text-red-400" >
295+ { error }
296+ </ div >
297+ </ >
198298 ) ;
199299 }
200300
201301 if ( payments . length === 0 ) {
202302 return (
203- < div className = "flex flex-col items-center justify-center rounded-lg border border-[#E8E8E8] bg-[#F9F9F9] p-8 text-center sm:p-16" >
204- < h3 className = "mb-3 text-lg font-bold text-[#0A0A0A] sm:text-xl" >
205- No activity detected
206- </ h3 >
207- < p className = "mb-8 max-w-sm text-sm font-medium text-[#6B6B6B] sm:text-base" >
208- Your live feed will populate here once you start receiving payments.
209- Create a link to get started.
210- </ p >
211- < Link
212- href = "/dashboard/create"
213- className = "w-full rounded-[6px] bg-[#0A0A0A] px-6 py-3 text-sm font-semibold text-white transition-all hover:bg-[#222] sm:w-auto"
214- >
215- Create First Payment
216- </ Link >
217- </ div >
303+ < >
304+ < Toaster position = "top-right" richColors closeButton />
305+ < div className = "flex flex-col items-center justify-center rounded-lg border border-[#E8E8E8] bg-[#F9F9F9] p-8 text-center sm:p-16" >
306+ < h3 className = "mb-3 text-lg font-bold text-[#0A0A0A] sm:text-xl" >
307+ No activity detected
308+ </ h3 >
309+ < p className = "mb-8 max-w-sm text-sm font-medium text-[#6B6B6B] sm:text-base" >
310+ Your live feed will populate here once you start receiving payments.
311+ Create a link to get started.
312+ </ p >
313+ < Link
314+ href = "/dashboard/create"
315+ className = "w-full rounded-[6px] bg-[#0A0A0A] px-6 py-3 text-sm font-semibold text-white transition-all hover:bg-[#222] sm:w-auto"
316+ >
317+ Create First Payment
318+ </ Link >
319+ </ div >
320+ </ >
218321 ) ;
219322 }
220323
221324 return (
222- < div className = "rounded-lg border border-[#E8E8E8] bg-white overflow-hidden" >
223- < div className = "flex items-center justify-between border-b border-[#E8E8E8] bg-[#0A0A0A] px-4 py-4 sm:px-6" >
224- < h3 className = "font-semibold text-white text-sm" > Live Activity Feed</ h3 >
225- < div className = "flex items-center gap-2 text-[10px] font-bold text-white/70 uppercase tracking-widest" >
226- < span className = "h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
227- Live
325+ < >
326+ < Toaster position = "top-right" richColors closeButton />
327+ < div className = "rounded-lg border border-[#E8E8E8] bg-white overflow-hidden" >
328+ < div className = "flex items-center justify-between border-b border-[#E8E8E8] bg-[#0A0A0A] px-4 py-4 sm:px-6" >
329+ < h3 className = "font-semibold text-white text-sm" > Live Activity Feed</ h3 >
330+ < div className = "flex items-center gap-2 text-[10px] font-bold text-white/70 uppercase tracking-widest" >
331+ < span className = "h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
332+ Live
333+ </ div >
228334 </ div >
229- </ div >
230335
231336 { /* ── Mobile card list (visible below sm breakpoint) ── */ }
232337 < div className = "space-y-3 p-4 sm:hidden" >
@@ -238,14 +343,15 @@ export default function ActivityFeed() {
238343 Focus-visible: keyboard-navigable with Pluto-500 ring. */
239344 tabIndex = { 0 }
240345 role = "row"
241- aria-label = { `Payment ${ payment . description || "Transaction" } for ${ formatAmount ( payment . amount , locale , hideCents ) } ${ payment . asset } ` }
346+ aria-label = { `Payment ${ payment . description || "Transaction" } for ${ formatAmount ( payment . amount , locale , hideCents ) } ${ payment . asset } ${ payment . isOptimistic ? " (Verifying)" : "" } ` }
242347 className = { `rounded-lg border p-3 outline-none
243348 transition-all duration-200 ease-in-out
244349 hover:border-l-2 hover:border-l-pluto-500 hover:bg-pluto-50 hover:shadow-sm
245350 active:scale-[0.985] active:bg-pluto-100
246351 focus-visible:ring-2 focus-visible:ring-pluto-500 focus-visible:ring-offset-2
247352 cursor-pointer
248- ${ i % 2 === 0 ? "border-[#E8E8E8] bg-white" : "border-[#ECECEC] bg-[#F9F9F9]" } ` }
353+ ${ i % 2 === 0 ? "border-[#E8E8E8] bg-white" : "border-[#ECECEC] bg-[#F9F9F9]" }
354+ ${ payment . isOptimistic ? "animate-pulse" : "" } ` }
249355 >
250356 < div className = "mb-2 flex items-start justify-between gap-3" >
251357 < p className = "truncate text-sm font-semibold text-[#0A0A0A]" >
@@ -258,9 +364,12 @@ export default function ActivityFeed() {
258364 ? "bg-[#F5F5F5] text-[#6B6B6B] border border-[#E8E8E8]"
259365 : "bg-red-50 text-red-600 border border-red-100"
260366 } `}
261- aria-label = { `Status: ${ payment . status } ` }
367+ aria-label = { `Status: ${ payment . status } ${ payment . isOptimistic ? " (Verifying)" : "" } ` }
262368 >
263369 { payment . status }
370+ { payment . isOptimistic && (
371+ < span className = "h-1 w-1 rounded-full bg-current animate-pulse" />
372+ ) }
264373 </ div >
265374 </ div >
266375 < div className = "flex items-center justify-between gap-3" >
@@ -310,5 +419,6 @@ export default function ActivityFeed() {
310419 </ table >
311420 </ div >
312421 </ div >
422+ </ >
313423 ) ;
314424}
0 commit comments