feat(geoportal-v2): inline CF order modal — confirmation + animated steps

Marius: click "Comandă CF" from the card itself, no new-tab to
parcel-sync. Show "Ești sigur? Costă 1 credit, mai ai X" first.
Animate the order through its phases until done.

New component cf-order-modal.tsx — a 7-state machine over a single
shadcn-style dialog:

  loading-status — checks /api/ancpi/session for connection + credits
  not-connected  — ePay session offline → prompt to connect via
                   parcel-sync (the only place credentials live)
  no-credits     — 0 credits, can't proceed
  ready          — confirmation: 1 credit cost, current balance,
                   projected balance after the order, all in
                   rounded chips with Coins icon
  placing        — POST /api/ancpi/order, spinner on step 1
  processing     — poll /api/ancpi/orders every 3s until status
                   becomes completed/done/minioPath populated.
                   Shows live elapsed seconds; 90s timeout falls
                   through to error with "verifică din nou peste
                   câteva minute".
  done           — checkmark anim + "Descarcă PDF" if document URL
                   came back
  error          — destructive panel + Reîncearcă button

Animations (tailwindcss-animate utilities):
  - Modal backdrop: fade-in 200ms
  - Modal card: zoom-in-95 + slide-in-from-bottom 200ms
  - Step rows: active row gets primary-tinted bg + Loader2 spin,
    done rows turn emerald + Check icon zooms in 300ms
  - Success/error final state: rounded badge + icon zooms in 500ms

Footer adapts per phase: Anulează+Confirmă (ready), Conectează ePay
(not-connected), Închide (loading/no-credits), Închide fereastra
(placing/processing — order continues in bg), Gata (done), Închide+
Reîncearcă (error).

Wires into feature-info-panel by replacing the "open /parcel-sync"
click handler with setCfModalOpen(true). Modal mounts at the
panel's root with fixed positioning + z-50 so it overlays the map.
Backdrop click dismisses except during placing/processing.

Uses the legacy /api/ancpi/* endpoints (not /api/cf/* gis-ac route)
per Marius's earlier decision to keep credit tracking on his own
ePay session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-20 07:53:15 +03:00
parent 8f86bab337
commit 5e4618b309
2 changed files with 586 additions and 7 deletions
+572
View File
@@ -0,0 +1,572 @@
"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 {
X, Loader2, Check, AlertCircle, FileText, Coins,
Plug, ArrowRight, Download,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
type Phase =
| "loading-status"
| "not-connected"
| "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;
const POLL_TIMEOUT_MS = 90_000;
export function CfOrderModal({
open,
cadastralRef,
siruta,
judetName,
uatName,
onClose,
}: Props) {
const [phase, setPhase] = useState<Phase>("loading-status");
const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [orderId, setOrderId] = useState<string | null>(null);
const [documentUrl, setDocumentUrl] = useState<string | null>(null);
const [elapsed, setElapsed] = useState(0);
const startedAtRef = useRef<number>(0);
const pollAbortRef = useRef<AbortController | null>(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 loadStatus = useCallback(async () => {
try {
const res = await fetch("/api/ancpi/session", { cache: "no-store" });
const data = (await res.json()) as SessionStatus;
setStatus(data);
if (!data.connected) {
setPhase("not-connected");
return;
}
if (data.credits != null && data.credits < 1) {
setPhase("no-credits");
return;
}
setPhase("ready");
} catch {
setError("Nu pot verifica sesiunea ePay.");
setPhase("error");
}
}, []);
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],
);
const goToParcelSync = useCallback(() => {
const url = `/parcel-sync?tab=epay&cad=${encodeURIComponent(cadastralRef)}`;
window.open(url, "_blank", "noopener,noreferrer");
}, [cadastralRef]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm animate-in fade-in duration-200"
onClick={(e) => {
// dismiss on backdrop click only when not mid-flight
if (
e.target === e.currentTarget &&
phase !== "placing" &&
phase !== "processing"
) {
onClose();
}
}}
>
<div className="relative w-full max-w-sm rounded-lg border bg-background shadow-2xl animate-in zoom-in-95 fade-in slide-in-from-bottom-2 duration-200">
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b px-4 py-3">
<div className="min-w-0">
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
<FileText className="h-4 w-4" />
Comandă extras CF
</h2>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{cadastralRef}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={phase === "placing" || phase === "processing"}
aria-label="Închide"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="px-4 py-4">
{/* Loading session status */}
{phase === "loading-status" && (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Verificare credite ePay</span>
</div>
)}
{/* Not connected */}
{phase === "not-connected" && (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
<Plug className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="space-y-1">
<p className="font-medium text-amber-900 dark:text-amber-200">
ePay neconectat
</p>
<p className="text-xs text-amber-800/80 dark:text-amber-200/80">
Conectează-te în secțiunea Parcel Sync ePay pentru
a putea comanda extrase CF.
</p>
</div>
</div>
<button
type="button"
onClick={goToParcelSync}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Conectează ePay
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* No credits */}
{phase === "no-credits" && (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
<Coins className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="space-y-1">
<p className="font-medium text-destructive">
Credite insuficiente
</p>
<p className="text-xs text-destructive/80">
Ai 0 credite. Reîncarcă-ți contul ePay ANCPI înainte
de a comanda.
</p>
</div>
</div>
</div>
)}
{/* Ready — confirmation */}
{phase === "ready" && (
<div className="space-y-3">
<p className="text-sm">
Ești pe cale comanzi un extras de Carte Funciară pentru
această parcelă.
</p>
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2.5 text-sm">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Cost</span>
</div>
<span className="font-mono font-semibold">1 credit</span>
</div>
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2.5 text-sm">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Sold disponibil</span>
</div>
<span className="font-mono font-semibold tabular-nums">
{status?.credits ?? "—"} credite
</span>
</div>
<div className="flex items-center justify-between rounded-md border border-emerald-500/30 bg-emerald-50 px-3 py-2.5 text-sm dark:bg-emerald-950/30">
<span className="text-muted-foreground">După comandă</span>
<span className="font-mono font-semibold tabular-nums text-emerald-700 dark:text-emerald-300">
{status?.credits != null
? `${status.credits - 1} credite`
: "—"}
</span>
</div>
</div>
)}
{/* Placing or Processing */}
{(phase === "placing" || phase === "processing") && (
<div className="space-y-3">
<StepRow
done={phase !== "placing"}
active={phase === "placing"}
label="Plasare comandă ANCPI"
/>
<StepRow
done={false}
active={phase === "processing"}
pending={phase === "placing"}
label={
phase === "processing"
? `Procesare la ANCPI… (${elapsed}s)`
: "Procesare la ANCPI"
}
/>
<StepRow
done={false}
active={false}
pending
label="Document gata"
/>
{phase === "processing" && (
<p className="pt-1 text-[11px] italic text-muted-foreground">
Poate dura până la 60 de secunde. Poți închide fereastra
comanda continuă în fundal.
</p>
)}
</div>
)}
{/* Done */}
{phase === "done" && (
<div className="space-y-3">
<div className="flex flex-col items-center gap-2 py-2 text-center animate-in zoom-in-95 duration-300">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
<Check className="h-7 w-7 text-emerald-600 animate-in zoom-in-50 duration-500" />
</div>
<div>
<p className="font-medium">Extras CF gata!</p>
<p className="text-xs text-muted-foreground">
Documentul a fost generat și descărcat în spațiul tău.
</p>
</div>
</div>
{documentUrl && (
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
<Download className="h-3.5 w-3.5" />
Descarcă PDF
</a>
)}
</div>
)}
{/* Error */}
{phase === "error" && (
<div className="space-y-3">
<div className="flex flex-col items-center gap-2 py-2 text-center animate-in zoom-in-95 duration-300">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
</div>
<div>
<p className="font-medium">Comanda nu a putut fi plasată</p>
<p className="text-xs text-muted-foreground">
{error ?? "Eroare necunoscută"}
</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t bg-muted/30 px-4 py-3">
{phase === "ready" && (
<>
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Anulează
</button>
<button
type="button"
onClick={placeOrder}
className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Confirmă · 1 credit
<ArrowRight className="h-3.5 w-3.5" />
</button>
</>
)}
{(phase === "loading-status" || phase === "no-credits") && (
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Închide
</button>
)}
{phase === "not-connected" && (
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Renunță
</button>
)}
{(phase === "placing" || phase === "processing") && (
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
title="Comanda continuă în fundal"
>
Închide fereastra
</button>
)}
{phase === "done" && (
<button
type="button"
onClick={onClose}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Gata
</button>
)}
{phase === "error" && (
<>
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Închide
</button>
<button
type="button"
onClick={() => void loadStatus()}
className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Reîncearcă
</button>
</>
)}
</div>
</div>
</div>
);
}
function StepRow({
done,
active,
pending,
label,
}: {
done: boolean;
active: boolean;
pending?: boolean;
label: string;
}) {
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
active && "border-primary/30 bg-primary/5",
done && "border-emerald-500/30 bg-emerald-50 dark:bg-emerald-950/30",
pending && !active && "opacity-50",
)}
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
active && "bg-primary text-primary-foreground",
done && "bg-emerald-500 text-white",
pending && !active && "bg-muted text-muted-foreground",
)}
>
{done ? (
<Check className="h-3 w-3 animate-in zoom-in-50 duration-300" />
) : active ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
<span
className={cn(
done && "text-emerald-700 dark:text-emerald-300",
active && "font-medium",
)}
>
{label}
</span>
</div>
);
}