d70442e26f
Three independent issues from the live test on 50198: 1. CfOrderModal poll timeout was 90s but ANCPI orders routinely take 60-180s end-to-end. The 50198 order completed at 129s (logs show 6 polls before docs matched), but the modal had already errored out 39s before that with "Procesarea durează mai mult decât ne-am așteptat". Bump to 180s + update the user-facing copy from "60 de secunde" to "3 minute" so the expectation matches reality. 2. cfApiBase(useGisAc=true) routed pilot users to /api/cf which proxies to gis-api → gis_core."CfExtract", but the ePay queue still writes ONLY to architools_postgres."CfExtract". Pilot users were therefore blind to their own fresh orders in the listing + catalog checks (50198 invisible despite being completed + downloadable). Pin all CF API calls to legacy /api/ancpi until Faza H mirrors writes to gis-api too; the source of truth then becomes a single table. 3. Manual cleanup of one stuck order in gis_enrichment.CfExtract (354686, pending since 2026-05-19) — never advanced past `pending`, was showing up as "În coadă" in the Extrase CF tab for ~4 days. Set status=cancelled with an explanatory errorMessage. (Applied via direct SQL on postgres-gis; no code change for this.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
"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<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 [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
|
|
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 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(
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className="fixed inset-0 z-[100] 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>
|
|
)}
|
|
|
|
{/* Connecting to ePay (auto, env creds) */}
|
|
{phase === "connecting" && (
|
|
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span>Conectare la ePay ANCPI…</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Need credentials — server didn't have them or auto-connect failed */}
|
|
{phase === "need-credentials" && (
|
|
<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-xs dark:bg-amber-950/30">
|
|
<Plug className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
|
<div className="space-y-0.5">
|
|
<p className="font-medium text-amber-900 dark:text-amber-200">
|
|
Conectează-te la ePay ANCPI
|
|
</p>
|
|
<p className="text-amber-800/80 dark:text-amber-200/80">
|
|
{error ?? "Introdu credențialele tale ePay pentru a continua."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<form
|
|
className="space-y-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (!username || !password) return;
|
|
void tryAutoConnect({ username, password });
|
|
}}
|
|
>
|
|
<label className="block">
|
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
Utilizator
|
|
</span>
|
|
<input
|
|
type="text"
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className="mt-1 w-full rounded-md border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-primary"
|
|
placeholder="email@exemplu.ro"
|
|
required
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
Parolă
|
|
</span>
|
|
<input
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="mt-1 w-full rounded-md border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-primary"
|
|
required
|
|
/>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
disabled={!username || !password}
|
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
<Plug className="h-3.5 w-3.5" />
|
|
Conectează
|
|
</button>
|
|
<p className="pt-1 text-[10px] text-muted-foreground">
|
|
Credențialele sunt trimise direct la ANCPI; nu sunt
|
|
păstrate la noi după sfârșitul sesiunii.
|
|
</p>
|
|
</form>
|
|
</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 să 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 3 minute. 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 === "connecting" ||
|
|
phase === "no-credits" ||
|
|
phase === "need-credentials") && (
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
|
|
>
|
|
Închide
|
|
</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>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|