427 lines
17 KiB
TypeScript
427 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback } from "react";
|
|
import type { PredictionResult, CausalAgentType } from "@/lib/types";
|
|
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
|
|
import SymptomChecker from "@/components/SymptomChecker";
|
|
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
|
import LookalikeWarning from "@/components/LookalikeWarning";
|
|
import FlagButton from "@/components/FlagButton";
|
|
|
|
/**
|
|
* Individual disease result card with expandable sections.
|
|
*
|
|
* Collapsed state: disease name, confidence badge, causal agent type icon, one-sentence summary.
|
|
* Expanded state: full description, symptom list, cause list, treatment timeline, prevention tips.
|
|
* Smooth expand/collapse animation.
|
|
* "Was this helpful?" feedback buttons at the bottom.
|
|
*/
|
|
export default function DiseaseCard({
|
|
prediction,
|
|
rank,
|
|
isPrimary,
|
|
onDismiss,
|
|
}: {
|
|
prediction: PredictionResult;
|
|
rank: number;
|
|
isPrimary: boolean;
|
|
onDismiss?: () => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(isPrimary);
|
|
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
|
|
|
const { disease, confidence, lookalikeDiseases } = prediction;
|
|
const colors = getConfidenceColors(confidence.label);
|
|
const lookalikes = lookalikeDiseases ?? [];
|
|
|
|
const toggleExpand = useCallback(() => {
|
|
setExpanded((e) => !e);
|
|
}, []);
|
|
|
|
// One-sentence summary (first sentence of description)
|
|
const summary = disease.description.split(".")[0] + ".";
|
|
|
|
return (
|
|
<article
|
|
className={`
|
|
group/card relative rounded-xl border-2 overflow-hidden transition-all duration-200
|
|
${
|
|
isPrimary
|
|
? `${colors.border} ${colors.bg} shadow-md`
|
|
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
|
|
}
|
|
`}
|
|
>
|
|
{/* Primary diagnosis ribbon */}
|
|
{isPrimary && (
|
|
<div
|
|
className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
</svg>
|
|
Primary Diagnosis
|
|
</div>
|
|
)}
|
|
|
|
{/* Card header — clickable to expand/collapse */}
|
|
<button
|
|
type="button"
|
|
onClick={toggleExpand}
|
|
className="w-full px-4 pt-4 pb-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-green-500 focus-visible:ring-offset-2 rounded-t-xl"
|
|
aria-expanded={expanded}
|
|
aria-controls={`disease-card-body-${disease.id}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* Rank / causal agent icon */}
|
|
<div
|
|
className={`
|
|
flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold
|
|
${
|
|
isPrimary
|
|
? `${colors.accent} text-white`
|
|
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
|
}
|
|
`}
|
|
>
|
|
{rank}
|
|
</div>
|
|
|
|
{/* Disease info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
|
{disease.name}
|
|
</h3>
|
|
<CausalAgentIcon type={disease.causalAgentType} />
|
|
<ConfidenceBadge confidence={confidence} />
|
|
</div>
|
|
<p className="mt-0.5 text-xs italic text-zinc-500 dark:text-zinc-400">
|
|
{disease.scientificName}
|
|
</p>
|
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{summary}</p>
|
|
</div>
|
|
|
|
{/* Expand/collapse chevron */}
|
|
<svg
|
|
className={`h-5 w-5 shrink-0 text-zinc-400 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Card body — expandable content */}
|
|
<div
|
|
id={`disease-card-body-${disease.id}`}
|
|
className={`
|
|
overflow-hidden transition-all duration-300 ease-in-out
|
|
${expanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}
|
|
`}
|
|
>
|
|
<div className="px-4 pb-4 space-y-5">
|
|
<hr className="border-zinc-200 dark:border-zinc-700" />
|
|
|
|
{/* Full description */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Description
|
|
</h4>
|
|
<FlagButton
|
|
contentType="disease_description"
|
|
contentId={disease.id}
|
|
fieldName="description"
|
|
label="description"
|
|
small
|
|
/>
|
|
</div>
|
|
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
{disease.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Symptom checker */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
|
Symptom Checker
|
|
</h4>
|
|
<FlagButton
|
|
contentType="disease_symptoms"
|
|
contentId={disease.id}
|
|
fieldName="symptoms"
|
|
label="symptoms"
|
|
small
|
|
/>
|
|
</div>
|
|
<SymptomChecker symptoms={disease.symptoms} />
|
|
</div>
|
|
|
|
{/* Causes */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
|
<svg
|
|
className="h-4 w-4 text-zinc-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
Causes & Contributing Factors
|
|
</h4>
|
|
<FlagButton
|
|
contentType="disease_causes"
|
|
contentId={disease.id}
|
|
fieldName="causes"
|
|
label="causes"
|
|
small
|
|
/>
|
|
</div>
|
|
<ul className="space-y-1.5" role="list">
|
|
{disease.causes.map((cause, i) => (
|
|
<li key={i} className="flex items-start gap-2">
|
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">{cause}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Treatment timeline */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
|
<svg
|
|
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
Treatment Plan
|
|
</h4>
|
|
<FlagButton
|
|
contentType="disease_treatment"
|
|
contentId={disease.id}
|
|
fieldName="treatment"
|
|
label="treatment"
|
|
small
|
|
/>
|
|
</div>
|
|
<TreatmentTimeline
|
|
steps={treatmentStepsWithUrgency(disease.treatment)}
|
|
severity={disease.severity}
|
|
/>
|
|
</div>
|
|
|
|
{/* Prevention tips */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
|
<svg
|
|
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
Prevention Tips
|
|
</h4>
|
|
<FlagButton
|
|
contentType="disease_prevention"
|
|
contentId={disease.id}
|
|
fieldName="prevention"
|
|
label="prevention tips"
|
|
small
|
|
/>
|
|
</div>
|
|
<ul className="space-y-1.5" role="list">
|
|
{disease.prevention.map((tip, i) => (
|
|
<li key={i} className="flex items-start gap-2">
|
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">{tip}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Lookalike warnings */}
|
|
{lookalikes.length > 0 && <LookalikeWarning disease={disease} lookalikes={lookalikes} />}
|
|
|
|
{/* Feedback buttons */}
|
|
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-2">
|
|
Was this diagnosis helpful?
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeedback("yes")}
|
|
className={`
|
|
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
|
transition-colors
|
|
${
|
|
feedback === "yes"
|
|
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
|
|
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
|
}
|
|
`}
|
|
aria-pressed={feedback === "yes"}
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
|
</svg>
|
|
Yes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeedback("no")}
|
|
className={`
|
|
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
|
transition-colors
|
|
${
|
|
feedback === "no"
|
|
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
|
|
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
|
}
|
|
`}
|
|
aria-pressed={feedback === "no"}
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path d="M18 10.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 10.667V5.236a2 2 0 00-1.105-1.795l-.05-.025A4 4 0 0011.057 2H5.641a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
|
|
</svg>
|
|
No
|
|
</button>
|
|
{feedback && (
|
|
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-2">
|
|
Thanks for your feedback!
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dismiss button (top-right corner, visible on hover) */}
|
|
{onDismiss && (
|
|
<button
|
|
type="button"
|
|
onClick={onDismiss}
|
|
className="absolute top-3 right-3 z-10 rounded-lg p-1 text-zinc-400 opacity-0 transition-opacity hover:text-zinc-600 dark:hover:text-zinc-300 group-hover/card:opacity-100"
|
|
aria-label="Dismiss this result"
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Causal agent type icon — shows a small icon based on disease type.
|
|
*/
|
|
function CausalAgentIcon({ type }: { type: CausalAgentType }) {
|
|
const config = getCausalAgentConfig(type);
|
|
|
|
return (
|
|
<span
|
|
className={`
|
|
inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium
|
|
${config.bg} ${config.text}
|
|
`}
|
|
title={`${config.label} disease`}
|
|
>
|
|
{config.icon}
|
|
<span className="hidden sm:inline">{config.label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface CausalAgentConfig {
|
|
label: string;
|
|
bg: string;
|
|
text: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
function getCausalAgentConfig(type: CausalAgentType): CausalAgentConfig {
|
|
switch (type) {
|
|
case "fungal":
|
|
return {
|
|
label: "Fungal",
|
|
bg: "bg-purple-100 dark:bg-purple-900/50",
|
|
text: "text-purple-700 dark:text-purple-300",
|
|
icon: (
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
<circle cx="8" cy="6" r="3" />
|
|
<circle cx="5" cy="10" r="2" />
|
|
<circle cx="11" cy="10" r="2" />
|
|
</svg>
|
|
),
|
|
};
|
|
case "bacterial":
|
|
return {
|
|
label: "Bacterial",
|
|
bg: "bg-blue-100 dark:bg-blue-900/50",
|
|
text: "text-blue-700 dark:text-blue-300",
|
|
icon: (
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
<ellipse cx="8" cy="8" rx="5" ry="2.5" />
|
|
</svg>
|
|
),
|
|
};
|
|
case "viral":
|
|
return {
|
|
label: "Viral",
|
|
bg: "bg-pink-100 dark:bg-pink-900/50",
|
|
text: "text-pink-700 dark:text-pink-300",
|
|
icon: (
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
<circle cx="8" cy="8" r="2.5" />
|
|
<line x1="8" y1="1" x2="8" y2="4" stroke="currentColor" strokeWidth="1" />
|
|
<line x1="8" y1="12" x2="8" y2="15" stroke="currentColor" strokeWidth="1" />
|
|
<line x1="1" y1="8" x2="4" y2="8" stroke="currentColor" strokeWidth="1" />
|
|
<line x1="12" y1="8" x2="15" y2="8" stroke="currentColor" strokeWidth="1" />
|
|
</svg>
|
|
),
|
|
};
|
|
case "environmental":
|
|
return {
|
|
label: "Environmental",
|
|
bg: "bg-orange-100 dark:bg-orange-900/50",
|
|
text: "text-orange-700 dark:text-orange-300",
|
|
icon: (
|
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2a1 1 0 011 1v2a1 1 0 01-2 0V4a1 1 0 011-1z" />
|
|
</svg>
|
|
),
|
|
};
|
|
}
|
|
}
|