import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, Link } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import Footer from "../components/layout/Footer";
import { Spinner } from "../components/ui/Spinner";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import {
AlertIcon,
ArrowLeftIcon,
DownloadIcon,
SparkleIcon,
UsersIcon,
} from "../components/ui/Icons";
import { downloadExport, searchResearchersBulk } from "../services/api";
import {
DEFAULT_EXPORT_DESTINATION,
DEFAULT_EXPORT_PROFILE,
resolveExportFromDestination,
swordXmlFilename,
} from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext";
const EXPORT_COOLDOWN_MS = 5000;
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
/**
* Group results view: shows one summary card per researcher, plus a global
* "download all new" (or "download everything") action in the header.
*
* Receives `{ orcidIds: string[] }` via `location.state` (set by LandingPage).
* If no state is present the user is redirected back to `/`.
*/
export function GroupResultsPage() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const orcidIds = location.state?.orcidIds;
const [results, setResults] = useState([]);
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [globalExporting, setGlobalExporting] = useState(null); // format | null
const [globalExportDestination, setGlobalExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
// Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({});
const [globalExportCooldownActive, setGlobalExportCooldownActive] =
useState(false);
const globalExportInFlightRef = useRef(false);
const globalExportCooldownUntilRef = useRef(0);
const globalExportCooldownTimerRef = useRef(null);
const [cardExportCooldownActive, setCardExportCooldownActive] = useState(
{},
);
const cardExportInFlightRef = useRef(new Set());
const cardExportCooldownUntilRef = useRef({});
const cardExportCooldownTimerRef = useRef({});
const abortRef = useRef(null);
useEffect(() => {
if (!orcidIds || orcidIds.length === 0) {
navigate("/", { replace: true });
return;
}
const ctrl = new AbortController();
abortRef.current = ctrl;
(async () => {
setLoading(true);
try {
const data = await searchResearchersBulk(orcidIds, {
signal: ctrl.signal,
});
if (ctrl.signal.aborted) return;
setResults(data.results ?? []);
setErrors(data.errors ?? []);
const failCount = (data.errors ?? []).length;
const okCount = (data.results ?? []).length;
if (failCount > 0) {
toast.warning(
`${okCount} investigador${okCount !== 1 ? "es" : ""} cargado${okCount !== 1 ? "s" : ""}, ${failCount} con error`,
{
description: "Comprueba los ORCID iDs que fallaron abajo.",
},
);
}
} catch (err) {
if (ctrl.signal.aborted) return;
toast.error("Error al buscar investigadores", {
description: err?.message ?? "Inténtalo de nuevo.",
});
setResults([]);
setErrors([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
})();
return () => ctrl.abort();
}, [orcidIds, navigate]);
useEffect(() => {
const cardTimersObj = cardExportCooldownTimerRef.current;
return () => {
const globalTimer = globalExportCooldownTimerRef.current;
if (globalTimer) {
clearTimeout(globalTimer);
}
for (const t of Object.values(cardTimersObj)) {
clearTimeout(t);
}
};
}, []);
function startGlobalExportCooldown() {
globalExportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
setGlobalExportCooldownActive(true);
if (globalExportCooldownTimerRef.current) {
clearTimeout(globalExportCooldownTimerRef.current);
}
globalExportCooldownTimerRef.current = setTimeout(() => {
setGlobalExportCooldownActive(false);
globalExportCooldownUntilRef.current = 0;
globalExportCooldownTimerRef.current = null;
}, EXPORT_COOLDOWN_MS);
}
function startCardExportCooldown(orcidId) {
const until = Date.now() + EXPORT_COOLDOWN_MS;
cardExportCooldownUntilRef.current[orcidId] = until;
setCardExportCooldownActive((prev) => ({
...prev,
[orcidId]: true,
}));
if (cardExportCooldownTimerRef.current[orcidId]) {
clearTimeout(cardExportCooldownTimerRef.current[orcidId]);
}
cardExportCooldownTimerRef.current[orcidId] = setTimeout(() => {
setCardExportCooldownActive((prev) => ({
...prev,
[orcidId]: false,
}));
cardExportCooldownUntilRef.current[orcidId] = 0;
cardExportCooldownTimerRef.current[orcidId] = null;
}, EXPORT_COOLDOWN_MS);
}
// All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => {
if (!isAuthenticated) return [];
return results.flatMap((r) =>
(r.publications ?? [])
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id),
);
}, [results, isAuthenticated]);
const allIds = useMemo(
() => results.flatMap((r) => (r.publications ?? []).map((p) => p.id)),
[results],
);
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
if (
globalExportInFlightRef.current ||
Date.now() < globalExportCooldownUntilRef.current
) {
return;
}
const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) {
toast.info(
isAuthenticated
? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar",
{
id: GLOBAL_EXPORT_TOAST_ID,
description:
"No se encontraron publicaciones en los investigadores cargados.",
},
);
return;
}
globalExportInFlightRef.current = true;
setGlobalExporting(format);
try {
// For bulk export we send all IDs together, passing a placeholder orcid
// since the endpoint is POST /export/{format}/publications (no orcid needed)
const { blob } = await downloadExport(null, format, {
publicationIds: ids,
profile: format === "xml" ? profile : undefined,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download =
format === "xml"
? swordXmlFilename("group", profile)
: `sword-group.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: `${ids.length} publicaciones exportadas.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setGlobalExporting(null);
globalExportInFlightRef.current = false;
startGlobalExportCooldown();
}
}
async function handleCardExport(
orcidId,
format,
newIds,
totalIds,
profile = DEFAULT_EXPORT_PROFILE,
) {
if (cardExportInFlightRef.current.has(orcidId)) {
return;
}
const now = Date.now();
const until = cardExportCooldownUntilRef.current[orcidId] ?? 0;
if (now < until) return;
const ids = isAuthenticated ? newIds : totalIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar", {
id: `group-export-card-${orcidId}`,
});
return;
}
cardExportInFlightRef.current.add(orcidId);
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try {
const { blob } = await downloadExport(orcidId, format, {
publicationIds: ids,
profile: format === "xml" ? profile : undefined,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download =
format === "xml"
? swordXmlFilename(orcidId, profile)
: `sword-${orcidId}.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: `group-export-card-${orcidId}`,
description: `${ids.length} publicaciones de ${orcidId}.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: `group-export-card-${orcidId}`,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setCardExporting((prev) => {
const next = { ...prev };
delete next[orcidId];
return next;
});
cardExportInFlightRef.current.delete(orcidId);
startCardExportCooldown(orcidId);
}
}
return (
Volver al inicio
{/* Page header */}
Búsqueda grupal
{!loading && (
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
{errors.length > 0 && (
· {errors.length} con error
)}
)}
{/* Global export buttons */}
{!loading && results.length > 0 && (
)}
{/* Loading state */}
{loading && (
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
)}
{/* Results grid */}
{!loading && results.length > 0 && (
{results.map((bundle) => (
{
const { format, profile } = resolveExportFromDestination(
globalExportDestination,
);
handleCardExport(
bundle.researcher?.orcid_id,
format,
newIds,
totalIds,
profile ?? DEFAULT_EXPORT_PROFILE,
);
}}
/>
))}
)}
{/* Errors */}
{!loading && errors.length > 0 && (
ORCID iDs que no pudieron cargarse
{errors.map((e) => (
{e.orcid_id}
{e.detail ?? "No se pudo obtener información de este ORCID."}
))}
)}
{/* Empty state */}
{!loading && results.length === 0 && errors.length === 0 && (
No se encontraron resultados.
Volver al inicio
)}
);
}
/* ─────────────────────────── Researcher card ─────────────────────────── */
function ResearcherResultCard({
bundle,
isAuthenticated,
exporting,
exportCooldownActive,
onExport,
}) {
const researcher = bundle.researcher ?? {};
const publications = bundle.publications ?? [];
const totalRecords = bundle.totalRecords ?? publications.length;
const newIds = isAuthenticated
? publications.filter((p) => p.downloaded_by_me === false).map((p) => p.id)
: [];
const allPubIds = publications.map((p) => p.id);
const newCount = newIds.length;
const hasNew = isAuthenticated && newCount > 0;
const initials = getInitials(researcher.name);
return (
{/* Researcher identity */}
{initials}
{researcher.name || "Sin nombre"}
{researcher.orcid_id}
{researcher.affiliation && (
{researcher.affiliation}
)}
{/* Stats row */}
{totalRecords}
publicaciones
{isAuthenticated && (
<>
>
)}
{/* Actions */}
Ver detalle
onExport(newIds, allPubIds)}
exporting={exporting}
isAuthenticated={isAuthenticated}
hasNew={hasNew}
newCount={newCount}
totalCount={totalRecords}
exportCooldownActive={exportCooldownActive}
/>
);
}
/* ─────────────────────── Per-card download button ────────────────────── */
function CardExportButton({
onClick,
exporting,
isAuthenticated,
hasNew,
newCount,
totalCount,
exportCooldownActive,
}) {
const isBusy = Boolean(exporting);
const disabled =
isBusy ||
exportCooldownActive ||
(isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
let label;
if (isBusy) {
label = `Descargando ${exporting.toUpperCase()}...`;
} else if (exportCooldownActive) {
label = "Espera un momento...";
} else if (isAuthenticated) {
label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
} else {
label = `Descargar (${totalCount})`;
}
return (
);
}
/* ─────────────────────────── Helpers ─────────────────────────────────── */
function getInitials(name) {
if (!name) return "?";
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("");
}
export default GroupResultsPage;