Skip to main content
DeveloperExamplesDisease Card

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

PropTypeRequiredDescription
mondoIdstringYesMONDO disease identifier
apiKeystringYesYour Kisho API key
expandedbooleanNoShow expanded details (default: false)
onClick(disease) => voidNoCallback when card is clicked
classNamestringNoAdditional CSS classes