Skip to content

Commit 7994367

Browse files
authored
feat(invoice): blockchain confirmation block counter widget (#546)
Closes #515 - Add GET /api/tx/[hash]/route.ts: queries Horizon for tx ledger and current ledger to compute confirmations - Add useTransactionConfirmations hook: polls every 5s, pauses on hidden tab, stops at FINALITY_THRESHOLD - Add ConfirmationCounter component: circular SVG arc filling to threshold, shows 'Confirmed' + checkmark when done
1 parent a86a293 commit 7994367

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

src/app/api/tx/[hash]/route.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon.stellar.org";
4+
5+
interface ConfirmationsResponse {
6+
confirmations: number;
7+
confirmed: boolean;
8+
ledger: number;
9+
currentLedger: number;
10+
}
11+
12+
export async function GET(
13+
_req: NextRequest,
14+
{ params }: { params: { hash: string } }
15+
): Promise<NextResponse<ConfirmationsResponse | { error: string }>> {
16+
const { hash } = params;
17+
18+
if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) {
19+
return NextResponse.json({ error: "Invalid transaction hash" }, { status: 400 });
20+
}
21+
22+
try {
23+
// Fetch the transaction and the latest ledger in parallel
24+
const [txRes, ledgersRes] = await Promise.all([
25+
fetch(`${HORIZON_URL}/transactions/${hash}`, { next: { revalidate: 0 } }),
26+
fetch(`${HORIZON_URL}/ledgers?order=desc&limit=1`, { next: { revalidate: 0 } }),
27+
]);
28+
29+
if (!txRes.ok) {
30+
const status = txRes.status === 404 ? 404 : 502;
31+
return NextResponse.json({ error: "Transaction not found" }, { status });
32+
}
33+
34+
const [txData, ledgersData] = await Promise.all([txRes.json(), ledgersRes.json()]);
35+
36+
const txLedger: number = txData.ledger;
37+
const currentLedger: number = ledgersData._embedded?.records?.[0]?.sequence ?? txLedger;
38+
const confirmations = Math.max(0, currentLedger - txLedger);
39+
40+
return NextResponse.json({
41+
confirmations,
42+
confirmed: confirmations >= 3,
43+
ledger: txLedger,
44+
currentLedger,
45+
});
46+
} catch {
47+
return NextResponse.json({ error: "Failed to fetch transaction data" }, { status: 502 });
48+
}
49+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"use client";
2+
3+
import { useTransactionConfirmations, FINALITY_THRESHOLD } from "@/hooks/useTransactionConfirmations";
4+
5+
interface Props {
6+
txHash: string;
7+
}
8+
9+
const RADIUS = 22;
10+
const STROKE = 4;
11+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
12+
13+
/**
14+
* ConfirmationCounter — shows a circular SVG progress arc that fills as
15+
* Stellar ledger confirmations increase toward FINALITY_THRESHOLD.
16+
* Polling stops when the threshold is reached.
17+
*/
18+
export default function ConfirmationCounter({ txHash }: Props) {
19+
const { confirmations, confirmed, loading, error } = useTransactionConfirmations(txHash);
20+
21+
const progress = Math.min(confirmations / FINALITY_THRESHOLD, 1);
22+
const dashOffset = CIRCUMFERENCE * (1 - progress);
23+
24+
if (loading && confirmations === 0) {
25+
return (
26+
<div
27+
aria-live="polite"
28+
aria-label="Waiting for confirmation data"
29+
className="flex items-center gap-2 text-sm text-slate-400"
30+
>
31+
<span className="h-4 w-4 rounded-full border-2 border-slate-600 border-t-brand-400 animate-spin" aria-hidden="true" />
32+
<span>Fetching confirmations…</span>
33+
</div>
34+
);
35+
}
36+
37+
if (error) {
38+
return (
39+
<div role="alert" className="text-xs text-red-400">
40+
Confirmation check failed: {error}
41+
</div>
42+
);
43+
}
44+
45+
if (confirmed) {
46+
return (
47+
<div
48+
role="status"
49+
aria-label="Transaction confirmed"
50+
className="flex items-center gap-2 text-sm font-medium text-green-400"
51+
>
52+
{/* Green checkmark circle */}
53+
<svg
54+
width={56}
55+
height={56}
56+
viewBox={`0 0 ${(RADIUS + STROKE) * 2} ${(RADIUS + STROKE) * 2}`}
57+
aria-hidden="true"
58+
>
59+
<circle
60+
cx={RADIUS + STROKE}
61+
cy={RADIUS + STROKE}
62+
r={RADIUS}
63+
fill="none"
64+
stroke="#16a34a"
65+
strokeWidth={STROKE}
66+
/>
67+
<path
68+
d={`M${RADIUS - 8 + STROKE} ${RADIUS + STROKE} l6 6 10-10`}
69+
fill="none"
70+
stroke="#16a34a"
71+
strokeWidth={2.5}
72+
strokeLinecap="round"
73+
strokeLinejoin="round"
74+
/>
75+
</svg>
76+
<span>Confirmed</span>
77+
</div>
78+
);
79+
}
80+
81+
return (
82+
<div
83+
role="status"
84+
aria-label={`${confirmations} of ${FINALITY_THRESHOLD} confirmations`}
85+
aria-live="polite"
86+
className="flex items-center gap-3"
87+
>
88+
{/* Circular SVG progress arc */}
89+
<svg
90+
width={56}
91+
height={56}
92+
viewBox={`0 0 ${(RADIUS + STROKE) * 2} ${(RADIUS + STROKE) * 2}`}
93+
aria-hidden="true"
94+
>
95+
{/* Track */}
96+
<circle
97+
cx={RADIUS + STROKE}
98+
cy={RADIUS + STROKE}
99+
r={RADIUS}
100+
fill="none"
101+
stroke="currentColor"
102+
strokeWidth={STROKE}
103+
className="text-slate-700"
104+
/>
105+
{/* Progress arc */}
106+
<circle
107+
cx={RADIUS + STROKE}
108+
cy={RADIUS + STROKE}
109+
r={RADIUS}
110+
fill="none"
111+
stroke="currentColor"
112+
strokeWidth={STROKE}
113+
strokeDasharray={CIRCUMFERENCE}
114+
strokeDashoffset={dashOffset}
115+
strokeLinecap="round"
116+
transform={`rotate(-90 ${RADIUS + STROKE} ${RADIUS + STROKE})`}
117+
className="text-brand-400 transition-[stroke-dashoffset] duration-500"
118+
style={{ strokeDashoffset: dashOffset }}
119+
/>
120+
{/* Counter text */}
121+
<text
122+
x={RADIUS + STROKE}
123+
y={RADIUS + STROKE + 1}
124+
textAnchor="middle"
125+
dominantBaseline="middle"
126+
className="fill-slate-200"
127+
style={{ fontSize: 10, fontWeight: 600 }}
128+
>
129+
{confirmations}/{FINALITY_THRESHOLD}
130+
</text>
131+
</svg>
132+
133+
<span className="text-sm text-slate-300">
134+
{confirmations} / {FINALITY_THRESHOLD} confirmations
135+
</span>
136+
</div>
137+
);
138+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef, useCallback } from "react";
4+
5+
export const FINALITY_THRESHOLD = 3;
6+
const POLL_INTERVAL_MS = 5_000;
7+
8+
export interface TransactionConfirmationsState {
9+
confirmations: number;
10+
confirmed: boolean;
11+
loading: boolean;
12+
error: string | null;
13+
}
14+
15+
/**
16+
* Polls /api/tx/[hash] every 5 seconds to track Stellar ledger confirmations.
17+
* Pauses polling when the browser tab is hidden (Page Visibility API).
18+
* Stops automatically once FINALITY_THRESHOLD confirmations are reached.
19+
*/
20+
export function useTransactionConfirmations(
21+
txHash: string | null | undefined
22+
): TransactionConfirmationsState {
23+
const [state, setState] = useState<TransactionConfirmationsState>({
24+
confirmations: 0,
25+
confirmed: false,
26+
loading: false,
27+
error: null,
28+
});
29+
30+
const stopPolling = useRef(false);
31+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
32+
33+
const poll = useCallback(async () => {
34+
if (!txHash || stopPolling.current || document.hidden) return;
35+
36+
try {
37+
const res = await fetch(`/api/tx/${txHash}`);
38+
if (!res.ok) {
39+
const data = await res.json().catch(() => ({}));
40+
setState((prev) => ({ ...prev, loading: false, error: data.error ?? "Fetch error" }));
41+
return;
42+
}
43+
const data: { confirmations: number; confirmed: boolean } = await res.json();
44+
setState({
45+
confirmations: data.confirmations,
46+
confirmed: data.confirmed,
47+
loading: false,
48+
error: null,
49+
});
50+
if (data.confirmed) {
51+
stopPolling.current = true;
52+
if (intervalRef.current) clearInterval(intervalRef.current);
53+
}
54+
} catch {
55+
setState((prev) => ({ ...prev, loading: false, error: "Network error" }));
56+
}
57+
}, [txHash]);
58+
59+
useEffect(() => {
60+
if (!txHash) return;
61+
62+
stopPolling.current = false;
63+
setState({ confirmations: 0, confirmed: false, loading: true, error: null });
64+
65+
// Initial fetch
66+
poll();
67+
68+
// Set up polling interval
69+
intervalRef.current = setInterval(poll, POLL_INTERVAL_MS);
70+
71+
// Pause/resume on visibility change
72+
const handleVisibility = () => {
73+
if (!document.hidden && !stopPolling.current) {
74+
poll();
75+
}
76+
};
77+
document.addEventListener("visibilitychange", handleVisibility);
78+
79+
return () => {
80+
if (intervalRef.current) clearInterval(intervalRef.current);
81+
document.removeEventListener("visibilitychange", handleVisibility);
82+
};
83+
}, [txHash, poll]);
84+
85+
return state;
86+
}

0 commit comments

Comments
 (0)