"use client"; // Inline CF order flow for the V2 panel — replaces the deep-link to // /parcel-sync. Marius rejected the gis-api shared-pool model // ([[architools-cutover-state-2026-05-19]]), so this stays on the // legacy `/api/ancpi/order` route which uses HIS connected ePay // session and decrements his own credit balance. // // State machine: // loading-status → checking ePay session + credit count // not-connected → user must connect ePay first (deep-link out) // no-credits → 0 credits, nothing to do // ready → confirmation step ("1 credit, mai ai X") // placing → POST /api/ancpi/order // processing → poll /api/ancpi/orders for completion // done → final state, optional download link // error → any failure with retry option import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { X, Loader2, Check, AlertCircle, FileText, Coins, Plug, ArrowRight, Download, } from "lucide-react"; import { cn } from "@/shared/lib/utils"; type Phase = | "loading-status" | "connecting" | "need-credentials" | "no-credits" | "ready" | "placing" | "processing" | "done" | "error"; interface SessionStatus { connected: boolean; credits?: number | null; } interface OrderListResponse { total?: number; orders?: Array<{ id: string; nrCadastral: string; status: string; epayStatus?: string | null; documentName?: string | null; minioPath?: string | null; completedAt?: string | null; }>; } interface Props { open: boolean; cadastralRef: string; siruta: string; /** Optional — passed through to the order body. The legacy route * tolerates blanks when judet/uat ids default to 0. */ judetName?: string; uatName?: string; onClose: () => void; } const POLL_INTERVAL_MS = 3_000; // ANCPI orders regularly take 60-180s end-to-end (depends on cart load // + ANCPI document queue); a 90s timeout was firing before the order // finished even when the queue completed correctly. 180s catches the // long tail without giving the user a "stuck forever" feel. const POLL_TIMEOUT_MS = 180_000; export function CfOrderModal({ open, cadastralRef, siruta, judetName, uatName, onClose, }: Props) { const [phase, setPhase] = useState("loading-status"); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [orderId, setOrderId] = useState(null); const [documentUrl, setDocumentUrl] = useState(null); const [elapsed, setElapsed] = useState(0); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const startedAtRef = useRef(0); const pollAbortRef = useRef(null); // Reset state on open useEffect(() => { if (!open) return; setPhase("loading-status"); setStatus(null); setError(null); setOrderId(null); setDocumentUrl(null); setElapsed(0); void loadStatus(); return () => { pollAbortRef.current?.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, cadastralRef, siruta]); // Elapsed timer during processing useEffect(() => { if (phase !== "processing" && phase !== "placing") return; const id = setInterval(() => { if (startedAtRef.current) { setElapsed(Math.round((Date.now() - startedAtRef.current) / 1000)); } }, 500); return () => clearInterval(id); }, [phase]); const evaluateStatus = useCallback((data: SessionStatus) => { setStatus(data); if (!data.connected) return "need-connect" as const; if (data.credits != null && data.credits < 1) { setPhase("no-credits"); return "no-credits" as const; } setPhase("ready"); return "ready" as const; }, []); const tryAutoConnect = useCallback( async (creds?: { username: string; password: string }) => { setPhase("connecting"); setError(null); try { const res = await fetch("/api/ancpi/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(creds ?? {}), }); const data = (await res.json()) as { success?: boolean; credits?: number | null; error?: string; }; if (!res.ok || data.error) { // 400 with empty body = no server-side env creds. Show the // inline credential form. 401 = bad credentials. if (creds) { setError(data.error ?? "Credențiale ePay invalide"); } setPhase("need-credentials"); return; } // Re-fetch status to confirm const sRes = await fetch("/api/ancpi/session", { cache: "no-store" }); const sData = (await sRes.json()) as SessionStatus; evaluateStatus(sData); } catch { setPhase("need-credentials"); setError("Nu s-a putut conecta automat. Introdu credențialele manual."); } }, [evaluateStatus], ); const loadStatus = useCallback(async () => { try { const res = await fetch("/api/ancpi/session", { cache: "no-store" }); const data = (await res.json()) as SessionStatus; const result = evaluateStatus(data); if (result === "need-connect") { // Session not active yet — silently try the server-side env // credentials (ANCPI_USERNAME / ANCPI_PASSWORD). Most installs // have these in Infisical so connection happens transparently. await tryAutoConnect(); } } catch { setError("Nu pot verifica sesiunea ePay."); setPhase("error"); } }, [evaluateStatus, tryAutoConnect]); const placeOrder = useCallback(async () => { setPhase("placing"); setError(null); startedAtRef.current = Date.now(); try { const res = await fetch("/api/ancpi/order", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parcels: [ { nrCadastral: cadastralRef, nrCF: null, siruta, judetIndex: 0, judetName: judetName ?? "", uatId: 0, uatName: uatName ?? "", }, ], }), }); const data = (await res.json()) as { orders?: Array<{ id: string; nrCadastral: string; status: string }>; error?: string; }; if (!res.ok || data.error) { setError(data.error ?? "Eroare la plasarea comenzii"); setPhase("error"); return; } const placed = (data.orders ?? [])[0]; if (!placed) { setError("Răspuns invalid de la server"); setPhase("error"); return; } setOrderId(placed.id); setPhase("processing"); void pollUntilDone(placed.id); } catch { setError("Eroare de rețea"); setPhase("error"); } }, [cadastralRef, siruta, judetName, uatName]); const pollUntilDone = useCallback( async (id: string) => { pollAbortRef.current?.abort(); const ctrl = new AbortController(); pollAbortRef.current = ctrl; const startedAt = Date.now(); while (!ctrl.signal.aborted) { if (Date.now() - startedAt > POLL_TIMEOUT_MS) { setError( "Procesarea durează mai mult decât ne-am așteptat. Comanda e plasată — verifică din nou peste câteva minute.", ); setPhase("error"); return; } try { const res = await fetch( `/api/ancpi/orders?nrCadastral=${encodeURIComponent(cadastralRef)}&limit=5`, { signal: ctrl.signal, cache: "no-store" }, ); if (res.ok) { const data = (await res.json()) as OrderListResponse; const row = (data.orders ?? []).find((o) => o.id === id); if (row) { const s = (row.status || "").toLowerCase(); if ( s === "completed" || s === "done" || row.minioPath || row.documentName ) { setDocumentUrl(`/api/ancpi/download?id=${row.id}`); setPhase("done"); return; } if (s === "failed" || s === "error") { setError("Comanda a eșuat la ANCPI."); setPhase("error"); return; } } } } catch { if (ctrl.signal.aborted) return; } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } }, [cadastralRef], ); if (!open) return null; // Render via portal so the modal escapes the panel's containing // block — the parent V2 panel uses backdrop-blur-md which (per CSS // spec) creates a containing block for `fixed` descendants. Without // the portal the modal anchors to the panel's top-right corner and // gets clipped above the viewport. if (typeof document === "undefined") return null; return createPortal(
{ // dismiss on backdrop click only when not mid-flight if ( e.target === e.currentTarget && phase !== "placing" && phase !== "processing" ) { onClose(); } }} >
{/* Header */}

Comandă extras CF

{cadastralRef}

{/* Body */}
{/* Loading session status */} {phase === "loading-status" && (
Verificare credite ePay…
)} {/* Connecting to ePay (auto, env creds) */} {phase === "connecting" && (
Conectare la ePay ANCPI…
)} {/* Need credentials — server didn't have them or auto-connect failed */} {phase === "need-credentials" && (

Conectează-te la ePay ANCPI

{error ?? "Introdu credențialele tale ePay pentru a continua."}

{ e.preventDefault(); if (!username || !password) return; void tryAutoConnect({ username, password }); }} >

Credențialele sunt trimise direct la ANCPI; nu sunt păstrate la noi după sfârșitul sesiunii.

)} {/* No credits */} {phase === "no-credits" && (

Credite insuficiente

Ai 0 credite. Reîncarcă-ți contul ePay ANCPI înainte de a comanda.

)} {/* Ready — confirmation */} {phase === "ready" && (

Ești pe cale să comanzi un extras de Carte Funciară pentru această parcelă.

Cost
1 credit
Sold disponibil
{status?.credits ?? "—"} credite
După comandă {status?.credits != null ? `${status.credits - 1} credite` : "—"}
)} {/* Placing or Processing */} {(phase === "placing" || phase === "processing") && (
{phase === "processing" && (

Poate dura până la 3 minute. Poți închide fereastra — comanda continuă în fundal.

)}
)} {/* Done */} {phase === "done" && (

Extras CF gata!

Documentul a fost generat și descărcat în spațiul tău.

{documentUrl && ( Descarcă PDF )}
)} {/* Error */} {phase === "error" && (

Comanda nu a putut fi plasată

{error ?? "Eroare necunoscută"}

)}
{/* Footer */}
{phase === "ready" && ( <> )} {(phase === "loading-status" || phase === "connecting" || phase === "no-credits" || phase === "need-credentials") && ( )} {(phase === "placing" || phase === "processing") && ( )} {phase === "done" && ( )} {phase === "error" && ( <> )}
, document.body, ); } function StepRow({ done, active, pending, label, }: { done: boolean; active: boolean; pending?: boolean; label: string; }) { return (
{done ? ( ) : active ? ( ) : ( )} {label}
); }