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:
@@ -141,17 +141,21 @@ export function EpayConnect({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError("Eroare retea");
|
setError("Eroare retea");
|
||||||
shouldRetry = attempt < maxRetries;
|
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 (cancelled) return;
|
||||||
|
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
// Keep connecting state true during retry wait
|
|
||||||
autoConnectTimerRef.current = setTimeout(() => {
|
autoConnectTimerRef.current = setTimeout(() => {
|
||||||
void attemptConnect(attempt + 1);
|
void attemptConnect(attempt + 1);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +168,11 @@ export function EpayConnect({
|
|||||||
autoConnectTimerRef.current = null;
|
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 () => {
|
const disconnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -202,10 +210,10 @@ export function EpayConnect({
|
|||||||
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{connecting ? (
|
{status.connected ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : status.connected ? (
|
|
||||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
) : connecting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<span className="hidden sm:inline">ePay</span>
|
<span className="hidden sm:inline">ePay</span>
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export function EpayOrderButton({
|
|||||||
|
|
||||||
const [ordering, setOrdering] = useState(false);
|
const [ordering, setOrdering] = useState(false);
|
||||||
const [ordered, setOrdered] = useState(false);
|
const [ordered, setOrdered] = useState(false);
|
||||||
|
// After enqueue the order keeps processing in the background (~1–2 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 [error, setError] = useState("");
|
||||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -111,7 +115,10 @@ export function EpayOrderButton({
|
|||||||
});
|
});
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
if (result.ok) {
|
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 {
|
} else {
|
||||||
setError(result.error ?? "Eroare comanda");
|
setError(result.error ?? "Eroare comanda");
|
||||||
}
|
}
|
||||||
@@ -119,6 +126,36 @@ export function EpayOrderButton({
|
|||||||
}
|
}
|
||||||
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
|
}, [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
|
// On the (future) gis.ac path, the orchestrator dispatches ePay calls
|
||||||
// through a shared account pool — no personally-connected ePay session
|
// through a shared account pool — no personally-connected ePay session
|
||||||
// needed. The legacy queue (current route while the guard is on)
|
// 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)";
|
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, ~1–2 min)
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (ordered) {
|
if (ordered) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user