/**
* FactCheckModal Component
*
* Displays the full fact-check result in a modal dialog.
* Shows verdict, confidence, rationale, and citations.
*/
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, ExternalLink, Clock, Database } from 'lucide-react';
import { useLocale } from 'next-intl';
import type { FactCheck, FactCheckVerdict, FactCheckCitation } from '@/lib/factcheck/types';
import { VERDICT_DISPLAY, getVerdictClasses } from '@/lib/factcheck/types';
interface FactCheckModalProps {
isOpen: boolean;
onClose: () => void;
factCheck: FactCheck;
isCached?: boolean;
}
export function FactCheckModal({
isOpen,
onClose,
factCheck,
isCached = false,
}: FactCheckModalProps) {
const locale = useLocale();
const display = VERDICT_DISPLAY[factCheck.verdict as FactCheckVerdict];
const modalRef = useRef<HTMLDivElement>(null);
// Parse citations if they're a string
const citations: FactCheckCitation[] = Array.isArray(factCheck.citations)
? factCheck.citations
: typeof factCheck.citations === 'string'
? JSON.parse(factCheck.citations)
: [];
// Handle escape key
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
// Handle click outside modal
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
}, [onClose]);
// Add/remove event listeners
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
modalRef.current?.focus();
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, handleEscape]);
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="factcheck-modal-title"
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 transition-opacity" />
{/* Modal Panel */}
<div
ref={modalRef}
tabIndex={-1}
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto transform rounded-lg bg-white dark:bg-gray-900 p-6 text-left shadow-xl transition-all animate-in fade-in zoom-in-95 duration-200"
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<h3
id="factcheck-modal-title"
className="text-lg font-semibold text-gray-900 dark:text-gray-100"
>
{locale === 'fr' ? 'Résultat de vérification' : 'Fact-Check Result'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
aria-label={locale === 'fr' ? 'Fermer' : 'Close'}
>
<X className="h-5 w-5" />
</button>
</div>
{/* Verdict Badge */}
<div className="mb-4">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-lg font-semibold ${getVerdictClasses(factCheck.verdict as FactCheckVerdict)}`}
>
<span className="text-xl">{display.icon}</span>
<span>
{locale === 'fr'
? translateVerdict(factCheck.verdict as FactCheckVerdict)
: display.label}
</span>
</div>
</div>
{/* Confidence */}
<div className="mb-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
{locale === 'fr' ? 'Confiance' : 'Confidence'}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${getConfidenceColor(factCheck.confidence)} transition-all`}
style={{ width: `${factCheck.confidence * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{Math.round(factCheck.confidence * 100)}%
</span>
</div>
</div>
{/* Rationale */}
<div className="mb-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
{locale === 'fr' ? 'Explication' : 'Rationale'}
</div>
<p className="text-gray-800 dark:text-gray-200 text-sm leading-relaxed">
{factCheck.rationale}
</p>
</div>
{/* Citations */}
{citations.length > 0 && (
<div className="mb-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{locale === 'fr' ? 'Sources' : 'Citations'} ({citations.length})
</div>
<div className="space-y-2">
{citations.map((citation, index) => (
<div
key={index}
className="p-2 bg-gray-50 dark:bg-gray-800 rounded-md text-sm"
>
<div className="flex items-start gap-2">
<ExternalLink className="h-4 w-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
{citation.url ? (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-red hover:underline font-medium"
>
{citation.title}
</a>
) : (
<span className="font-medium text-gray-800 dark:text-gray-200">
{citation.title}
</span>
)}
{citation.excerpt && (
<p className="text-gray-600 dark:text-gray-400 mt-1 text-xs">
“{citation.excerpt}”
</p>
)}
{citation.source_type && (
<span className="inline-block mt-1 px-1.5 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{citation.source_type}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
{isCached && (
<div className="flex items-center gap-1">
<Database className="h-3 w-3" />
<span>{locale === 'fr' ? 'Résultat mis en cache' : 'Cached result'}</span>
</div>
)}
{factCheck.processing_time_ms && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>
{factCheck.processing_time_ms}ms
</span>
</div>
)}
{factCheck.checked_at && (
<span>
{locale === 'fr' ? 'Vérifié le' : 'Checked'}{' '}
{new Date(factCheck.checked_at).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
{factCheck.model_used && (
<span className="text-gray-400">{factCheck.model_used}</span>
)}
</div>
{/* Disclaimer */}
<div className="mt-4 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded text-xs text-yellow-800 dark:text-yellow-200">
{locale === 'fr'
? "Les vérifications automatisées peuvent contenir des erreurs. Vérifiez toujours les sources primaires."
: "Automated fact-checks may contain errors. Always verify with primary sources."}
</div>
{/* Close button */}
<div className="mt-4 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{locale === 'fr' ? 'Fermer' : 'Close'}
</button>
</div>
</div>
</div>
);
// Render using portal to ensure modal is at the top of the DOM
if (typeof window !== 'undefined') {
return createPortal(modalContent, document.body);
}
return null;
}
/**
* Get confidence bar color based on score
*/
function getConfidenceColor(confidence: number): string {
if (confidence >= 0.8) return 'bg-green-500';
if (confidence >= 0.6) return 'bg-yellow-500';
if (confidence >= 0.4) return 'bg-orange-500';
return 'bg-red-500';
}
/**
* Translate verdict to French
*/
function translateVerdict(verdict: FactCheckVerdict): string {
switch (verdict) {
case 'TRUE':
return 'Vrai';
case 'FALSE':
return 'Faux';
case 'MISLEADING':
return 'Trompeur';
case 'NEEDS_CONTEXT':
return 'Contexte requis';
case 'UNVERIFIABLE':
return 'Non vérifiable';
default:
return verdict;
}
}