Disease Card Component
Beginner
A reusable React component that displays disease information with computed intelligence fields, quick stats, and external reference links.
DiseaseCard.tsx
/**
* Disease Card Component
*
* A reusable React component that displays disease information
* fetched from the Kisho API.
*/
import React, { useState, useEffect } from 'react';
// Types
interface Disease {
mondoId: string;
name: string;
definition: string | null;
genes: string[];
computed: {
hasApprovedTherapies: boolean;
approvedCount: number;
designatedCount: number;
trialCount: number;
trialCountCategory: 'none' | 'few' | 'some' | 'many';
hasKnownGeneticCause: boolean;
primaryGene: string | null;
reportCompleteness: number;
hasExpertReview: boolean;
};
classification: {
unmetNeedSignal: 'high' | 'moderate' | 'low';
pipelineActivity: 'crowded' | 'active' | 'sparse' | 'none';
researchActivity: 'active' | 'limited' | 'none';
};
crossReferences: {
omim: string[];
orphanet: string[];
};
}
interface DiseaseCardProps {
/** MONDO disease identifier */
mondoId: string;
/** Your Kisho API key */
apiKey: string;
/** Optional: Show expanded details */
expanded?: boolean;
/** Optional: Custom click handler */
onClick?: (disease: Disease) => void;
/** Optional: Custom class name */
className?: string;
}
// Badge Components
const UnmetNeedBadge: React.FC<{ signal: Disease['classification']['unmetNeedSignal'] }> = ({ signal }) => {
const styles = {
high: 'bg-red-100 text-red-700 border-red-200',
moderate: 'bg-amber-100 text-amber-700 border-amber-200',
low: 'bg-green-100 text-green-700 border-green-200'
};
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${styles[signal]}`}>
{signal.toUpperCase()} unmet need
</span>
);
};
const PipelineBadge: React.FC<{ activity: Disease['classification']['pipelineActivity'] }> = ({ activity }) => {
const styles = {
crowded: 'bg-blue-100 text-blue-700',
active: 'bg-indigo-100 text-indigo-700',
sparse: 'bg-purple-100 text-purple-700',
none: 'bg-gray-100 text-gray-600'
};
const labels = {
crowded: 'Crowded Pipeline',
active: 'Active Pipeline',
sparse: 'Sparse Pipeline',
none: 'No Pipeline Activity'
};
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${styles[activity]}`}>
{labels[activity]}
</span>
);
};
// Loading Skeleton
const DiseaseCardSkeleton: React.FC = () => (
<div className="bg-white rounded-lg border border-gray-200 p-4 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-3 bg-gray-200 rounded w-full mb-2" />
<div className="h-3 bg-gray-200 rounded w-5/6 mb-4" />
<div className="flex gap-2">
<div className="h-6 bg-gray-200 rounded w-20" />
<div className="h-6 bg-gray-200 rounded w-24" />
</div>
</div>
);
// Main Component
export const DiseaseCard: React.FC<DiseaseCardProps> = ({
mondoId,
apiKey,
expanded = false,
onClick,
className = ''
}) => {
const [disease, setDisease] = useState<Disease | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchDisease() {
setLoading(true);
setError(null);
try {
const response = await fetch(
`https://kishomed.io/api/v1/diseases/${encodeURIComponent(mondoId)}`,
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
setDisease(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load disease');
} finally {
setLoading(false);
}
}
fetchDisease();
}, [mondoId, apiKey]);
if (loading) return <DiseaseCardSkeleton />;
if (error) return <div className="bg-red-50 rounded-lg border border-red-200 p-4"><p className="text-red-700 text-sm">{error}</p></div>;
if (!disease) return null;
return (
<div
className={`bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow ${onClick ? 'cursor-pointer' : ''} ${className}`}
onClick={() => onClick?.(disease)}
>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-gray-900">{disease.name}</h3>
<p className="text-xs text-gray-500 font-mono">{disease.mondoId}</p>
</div>
{disease.computed.hasExpertReview && (
<span className="text-green-600" title="Expert Reviewed">✓</span>
)}
</div>
{/* Definition */}
{disease.definition && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{disease.definition}</p>
)}
{/* Badges */}
<div className="flex flex-wrap gap-2 mb-3">
<UnmetNeedBadge signal={disease.classification.unmetNeedSignal} />
<PipelineBadge activity={disease.classification.pipelineActivity} />
</div>
{/* Metrics */}
<div className="grid grid-cols-3 gap-2 text-center border-t border-gray-100 pt-3">
<div>
<div className="text-lg font-semibold text-gray-900">{disease.computed.approvedCount}</div>
<div className="text-xs text-gray-500">Approved</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{disease.computed.trialCount}</div>
<div className="text-xs text-gray-500">Trials</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{disease.computed.primaryGene || '—'}</div>
<div className="text-xs text-gray-500">Gene</div>
</div>
</div>
{/* Expanded details */}
{expanded && (
<div className="border-t border-gray-100 pt-3 mt-3 space-y-2">
{disease.genes.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Genes:</span>
<div className="flex flex-wrap gap-1">
{disease.genes.slice(0, 5).map(gene => (
<span key={gene} className="px-1.5 py-0.5 bg-gray-100 text-gray-700 text-xs rounded font-mono">
{gene}
</span>
))}
{disease.genes.length > 5 && (
<span className="text-xs text-gray-500">+{disease.genes.length - 5} more</span>
)}
</div>
</div>
)}
<div className="flex gap-3 text-xs">
{disease.crossReferences.omim.length > 0 && (
<a href={`https://omim.org/entry/${disease.crossReferences.omim[0]}`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline" onClick={e => e.stopPropagation()}>OMIM ↗</a>
)}
{disease.crossReferences.orphanet.length > 0 && (
<a href={`https://www.orpha.net/consor/cgi-bin/OC_Exp.php?Expert=${disease.crossReferences.orphanet[0].replace('ORPHA:', '')}`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline" onClick={e => e.stopPropagation()}>Orphanet ↗</a>
)}
</div>
</div>
)}
</div>
);
};
export default DiseaseCard;Props
| Prop | Type | Required | Description |
|---|---|---|---|
| mondoId | string | Yes | MONDO disease identifier |
| apiKey | string | Yes | Your Kisho API key |
| expanded | boolean | No | Show expanded details (default: false) |
| onClick | (disease) => void | No | Callback when card is clicked |
| className | string | No | Additional CSS classes |