Skip to content

Commit df91967

Browse files
authored
Merge pull request #1677 from floraispretty/feature/fe-implement-optimistic-state-rollback-with-toast-feedback-upon-network-failure-in-activity-feed-component
[Frontend] Implement optimistic state rollback with toast feedback upon network failure in Activity Feed Component
2 parents 7a4a3b4 + 809bbe4 commit df91967

1 file changed

Lines changed: 164 additions & 54 deletions

File tree

‎frontend/src/components/ActivityFeed.tsx‎

Lines changed: 164 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

3-
import { useEffect, useState, useCallback, useMemo, memo } from "react";
3+
import { useEffect, useState, useCallback, useMemo, memo, useRef } from "react";
44
import Link from "next/link";
55
import { useLocale } from "next-intl";
6+
import { toast, Toaster } from "sonner";
67
import {
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

2426
interface 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

Comments
 (0)