fix(epay-ui): stuck connect spinner + order button shows processing not instant-valid

Two UX issues reported from the field:

1. ePay pill spun forever on an already-connected (green) pill. Two causes:
   the icon put connecting before connected (so a stuck connecting state
   showed the spinner even when connected), and the auto-connect effect leaked
   the connecting state — a cancelled early-return skipped clearing it, and
   having connecting in the dep array made setConnecting(true) cancel its own
   in-flight attempt. Fix: connected takes icon priority; a finally{} always
   clears connecting unless retrying; drop connecting from deps.

2. The per-parcel CF button flipped straight to green "Extras CF valid" the
   instant the order was queued, while it actually kept processing ~1-2 min in
   the background (cart, submit, poll, download). Now it shows a pulsing
   "Se proceseaza..." and polls until a completed extract truly exists before
   flipping to valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-05 00:36:10 +03:00
parent b62132ab9e
commit 5ad8870dc5
2 changed files with 76 additions and 8 deletions
@@ -141,17 +141,21 @@ export function EpayConnect({
if (cancelled) return;
setError("Eroare retea");
shouldRetry = attempt < maxRetries;
} finally {
// ALWAYS clear the connecting spinner unless we're about to retry —
// including the `cancelled` early-returns above. Otherwise a re-run
// of this effect (e.g. when status.connected flips true) cancels the
// in-flight attempt and leaves connecting stuck true → a perpetual
// spinner on an already-connected (green) pill.
if (!shouldRetry) setConnecting(false);
}
if (cancelled) return;
if (shouldRetry) {
// Keep connecting state true during retry wait
autoConnectTimerRef.current = setTimeout(() => {
void attemptConnect(attempt + 1);
}, 3000);
} else {
setConnecting(false);
}
};
@@ -164,7 +168,11 @@ export function EpayConnect({
autoConnectTimerRef.current = null;
}
};
}, [triggerConnect, status.connected, connecting, fetchStatus]);
// `connecting` intentionally excluded: setConnecting(true) inside this
// effect would otherwise re-trigger it and cancel its own in-flight
// attempt. autoConnectAttempted (a ref) already prevents double-starts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [triggerConnect, status.connected, fetchStatus]);
const disconnect = async () => {
try {
@@ -202,10 +210,10 @@ export function EpayConnect({
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)}
>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : status.connected ? (
{status.connected ? (
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
) : connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : null}
<span className="hidden sm:inline">ePay</span>
@@ -57,6 +57,10 @@ export function EpayOrderButton({
const [ordering, setOrdering] = useState(false);
const [ordered, setOrdered] = useState(false);
// After enqueue the order keeps processing in the background (~12 min on
// the legacy queue): cart → submit → poll → download. Show that instead of
// flipping straight to a misleading "valid" the instant it's queued.
const [processing, setProcessing] = useState(false);
const [error, setError] = useState("");
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
connected: false,
@@ -111,7 +115,10 @@ export function EpayOrderButton({
});
if (mountedRef.current) {
if (result.ok) {
setOrdered(true);
// Queued, not done — enter the processing state and let the poll
// effect below flip to "valid" only once the extract is actually
// ready (or surface a failure).
setProcessing(true);
} else {
setError(result.error ?? "Eroare comanda");
}
@@ -119,6 +126,36 @@ export function EpayOrderButton({
}
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
// Poll while processing: flip to "valid" only when a completed extract
// actually exists. Caps at ~3 min, then stops (the parent list refresh
// will reflect the final state).
useEffect(() => {
if (!processing) return;
let cancelled = false;
let attempts = 0;
const tick = async () => {
attempts += 1;
try {
const has = await fetchCfHasCompletedForCadastral(useGisAc, nrCadastral);
if (cancelled) return;
if (has) {
setOrdered(true);
setProcessing(false);
return;
}
} catch {
/* keep polling */
}
if (!cancelled && attempts >= 36) setProcessing(false); // ~3 min
};
const id = setInterval(() => void tick(), 5000);
void tick();
return () => {
cancelled = true;
clearInterval(id);
};
}, [processing, useGisAc, nrCadastral]);
// On the (future) gis.ac path, the orchestrator dispatches ePay calls
// through a shared account pool — no personally-connected ePay session
// needed. The legacy queue (current route while the guard is on)
@@ -143,6 +180,29 @@ export function EpayOrderButton({
return tooltipText ?? "Comanda extras CF (1 credit)";
};
if (processing) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-1.5 text-yellow-600 dark:text-yellow-400"
disabled
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-[10px]">Se procesează...</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Comanda CF este în curs (coș plată descărcare, ~12 min)
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (ordered) {
return (
<TooltipProvider>