Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/app/api/tx/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";

const HORIZON_URL = process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon.stellar.org";

interface ConfirmationsResponse {
confirmations: number;
confirmed: boolean;
ledger: number;
currentLedger: number;
}

export async function GET(
_req: NextRequest,
{ params }: { params: { hash: string } }
): Promise<NextResponse<ConfirmationsResponse | { error: string }>> {
const { hash } = params;

if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) {
return NextResponse.json({ error: "Invalid transaction hash" }, { status: 400 });
}

try {
// Fetch the transaction and the latest ledger in parallel
const [txRes, ledgersRes] = await Promise.all([
fetch(`${HORIZON_URL}/transactions/${hash}`, { next: { revalidate: 0 } }),
fetch(`${HORIZON_URL}/ledgers?order=desc&limit=1`, { next: { revalidate: 0 } }),
]);

if (!txRes.ok) {
const status = txRes.status === 404 ? 404 : 502;
return NextResponse.json({ error: "Transaction not found" }, { status });
}

const [txData, ledgersData] = await Promise.all([txRes.json(), ledgersRes.json()]);

const txLedger: number = txData.ledger;
const currentLedger: number = ledgersData._embedded?.records?.[0]?.sequence ?? txLedger;
const confirmations = Math.max(0, currentLedger - txLedger);

return NextResponse.json({
confirmations,
confirmed: confirmations >= 3,
ledger: txLedger,
currentLedger,
});
} catch {
return NextResponse.json({ error: "Failed to fetch transaction data" }, { status: 502 });
}
}
138 changes: 138 additions & 0 deletions src/components/invoice/ConfirmationCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import { useTransactionConfirmations, FINALITY_THRESHOLD } from "@/hooks/useTransactionConfirmations";

interface Props {
txHash: string;
}

const RADIUS = 22;
const STROKE = 4;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

/**
* ConfirmationCounter — shows a circular SVG progress arc that fills as
* Stellar ledger confirmations increase toward FINALITY_THRESHOLD.
* Polling stops when the threshold is reached.
*/
export default function ConfirmationCounter({ txHash }: Props) {
const { confirmations, confirmed, loading, error } = useTransactionConfirmations(txHash);

const progress = Math.min(confirmations / FINALITY_THRESHOLD, 1);
const dashOffset = CIRCUMFERENCE * (1 - progress);

if (loading && confirmations === 0) {
return (
<div
aria-live="polite"
aria-label="Waiting for confirmation data"
className="flex items-center gap-2 text-sm text-slate-400"
>
<span className="h-4 w-4 rounded-full border-2 border-slate-600 border-t-brand-400 animate-spin" aria-hidden="true" />
<span>Fetching confirmations…</span>
</div>
);
}

if (error) {
return (
<div role="alert" className="text-xs text-red-400">
Confirmation check failed: {error}
</div>
);
}

if (confirmed) {
return (
<div
role="status"
aria-label="Transaction confirmed"
className="flex items-center gap-2 text-sm font-medium text-green-400"
>
{/* Green checkmark circle */}
<svg
width={56}
height={56}
viewBox={`0 0 ${(RADIUS + STROKE) * 2} ${(RADIUS + STROKE) * 2}`}
aria-hidden="true"
>
<circle
cx={RADIUS + STROKE}
cy={RADIUS + STROKE}
r={RADIUS}
fill="none"
stroke="#16a34a"
strokeWidth={STROKE}
/>
<path
d={`M${RADIUS - 8 + STROKE} ${RADIUS + STROKE} l6 6 10-10`}
fill="none"
stroke="#16a34a"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Confirmed</span>
</div>
);
}

return (
<div
role="status"
aria-label={`${confirmations} of ${FINALITY_THRESHOLD} confirmations`}
aria-live="polite"
className="flex items-center gap-3"
>
{/* Circular SVG progress arc */}
<svg
width={56}
height={56}
viewBox={`0 0 ${(RADIUS + STROKE) * 2} ${(RADIUS + STROKE) * 2}`}
aria-hidden="true"
>
{/* Track */}
<circle
cx={RADIUS + STROKE}
cy={RADIUS + STROKE}
r={RADIUS}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
className="text-slate-700"
/>
{/* Progress arc */}
<circle
cx={RADIUS + STROKE}
cy={RADIUS + STROKE}
r={RADIUS}
fill="none"
stroke="currentColor"
strokeWidth={STROKE}
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
strokeLinecap="round"
transform={`rotate(-90 ${RADIUS + STROKE} ${RADIUS + STROKE})`}
className="text-brand-400 transition-[stroke-dashoffset] duration-500"
style={{ strokeDashoffset: dashOffset }}
/>
{/* Counter text */}
<text
x={RADIUS + STROKE}
y={RADIUS + STROKE + 1}
textAnchor="middle"
dominantBaseline="middle"
className="fill-slate-200"
style={{ fontSize: 10, fontWeight: 600 }}
>
{confirmations}/{FINALITY_THRESHOLD}
</text>
</svg>

<span className="text-sm text-slate-300">
{confirmations} / {FINALITY_THRESHOLD} confirmations
</span>
</div>
);
}
86 changes: 86 additions & 0 deletions src/hooks/useTransactionConfirmations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useState, useEffect, useRef, useCallback } from "react";

export const FINALITY_THRESHOLD = 3;
const POLL_INTERVAL_MS = 5_000;

export interface TransactionConfirmationsState {
confirmations: number;
confirmed: boolean;
loading: boolean;
error: string | null;
}

/**
* Polls /api/tx/[hash] every 5 seconds to track Stellar ledger confirmations.
* Pauses polling when the browser tab is hidden (Page Visibility API).
* Stops automatically once FINALITY_THRESHOLD confirmations are reached.
*/
export function useTransactionConfirmations(
txHash: string | null | undefined
): TransactionConfirmationsState {
const [state, setState] = useState<TransactionConfirmationsState>({
confirmations: 0,
confirmed: false,
loading: false,
error: null,
});

const stopPolling = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

const poll = useCallback(async () => {
if (!txHash || stopPolling.current || document.hidden) return;

try {
const res = await fetch(`/api/tx/${txHash}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setState((prev) => ({ ...prev, loading: false, error: data.error ?? "Fetch error" }));
return;
}
const data: { confirmations: number; confirmed: boolean } = await res.json();
setState({
confirmations: data.confirmations,
confirmed: data.confirmed,
loading: false,
error: null,
});
if (data.confirmed) {
stopPolling.current = true;
if (intervalRef.current) clearInterval(intervalRef.current);
}
} catch {
setState((prev) => ({ ...prev, loading: false, error: "Network error" }));
}
}, [txHash]);

useEffect(() => {
if (!txHash) return;

stopPolling.current = false;
setState({ confirmations: 0, confirmed: false, loading: true, error: null });

// Initial fetch
poll();

// Set up polling interval
intervalRef.current = setInterval(poll, POLL_INTERVAL_MS);

// Pause/resume on visibility change
const handleVisibility = () => {
if (!document.hidden && !stopPolling.current) {
poll();
}
};
document.addEventListener("visibilitychange", handleVisibility);

return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [txHash, poll]);

return state;
}