project-nomad/admin/inertia/pages/drug-reference/index.tsx

921 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import DrugResultRow from '~/components/drug-reference/DrugResultRow'
import IngestStatus from '~/components/drug-reference/IngestStatus'
import SafetyBanner from '~/components/conditions/SafetyBanner'
import RemedySafetyNote from '~/components/conditions/RemedySafetyNote'
import { IconSearch, IconFirstAidKit, IconLeaf } from '@tabler/icons-react'
import type {
DrugSearchResult,
DrugIngestStatus,
} from '../../../types/drug_reference'
import type { ConditionSummary, ConditionDrugsResult, NaturalRemedy } from '../../../types/conditions'
import { remedySourceName } from '../../../util/conditions'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
interface PageProps {
ingestStatus: DrugIngestStatus | null
rowCount: number
conditions: ConditionSummary[]
remedies: NaturalRemedy[]
/**
* Affirmative-content gate (#1040). False by default: the server sends no
* remedy data and the "Natural" filter is hidden, so only FDA label search and
* condition→OTC matching show. Flipped on after a clinician content-pass.
*/
remediesEnabled?: boolean
}
/**
* Sentinel for the type filter's "Natural" pill. Not an FDA product_type — it
* routes the search to the curated NCCIH herb list instead of drug_labels.
*/
const NATURAL_FILTER = 'NATURAL'
/**
* Curated administration routes for the route filter — the common openFDA
* `route` values a field user actually reaches for. The column holds a
* comma-joined uppercase list, so the backend matches with LIKE.
*/
const ROUTE_OPTIONS = [
'ORAL',
'TOPICAL',
'OPHTHALMIC',
'OTIC',
'NASAL',
'INHALATION',
'SUBLINGUAL',
'RECTAL',
'VAGINAL',
'TRANSDERMAL',
'DENTAL',
] as const
/** Title-case a route value for display (ORAL → Oral). */
function routeLabel(r: string): string {
return r.charAt(0) + r.slice(1).toLowerCase()
}
/** Case-insensitive remedy match on name / common names / uses. */
function matchRemedies(remedies: NaturalRemedy[], query: string): NaturalRemedy[] {
const q = query.trim().toLowerCase()
if (!q) return []
return remedies.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.commonNames.some((cn) => cn.toLowerCase().includes(q)) ||
r.uses.toLowerCase().includes(q)
)
}
const DEBOUNCE_MS = 350
const LIMIT = 50
/** Shared elevated-card surface for the result and detail panels. */
const CARD_SURFACE =
'rounded-2xl border border-desert-stone-lighter/60 bg-desert-white ' +
'shadow-[0_1px_2px_rgba(66,68,32,0.04),0_8px_24px_-12px_rgba(66,68,32,0.12)]'
/** Read an initial situation slug from ?situation= (reverse-link deep link). */
function initialSituationSlug(): string | null {
if (typeof window === 'undefined') return null
return new URLSearchParams(window.location.search).get('situation')
}
/**
* Match a free-text query to a curated situation by slug or label (case-insensitive:
* exact first, then "contains"). Returns the matched summary or null. The curated
* `searchTerms` are server-only, so off-list queries fall through to free-text on the
* API — which still resolves a situation against the FULLTEXT index.
*/
function matchSituation(query: string, conditions: ConditionSummary[]): ConditionSummary | null {
const q = query.trim().toLowerCase()
if (!q) return null
const exact = conditions.find((c) => c.slug.toLowerCase() === q || c.label.toLowerCase() === q)
if (exact) return exact
return (
conditions.find(
(c) => c.label.toLowerCase().includes(q) || c.slug.replace(/-/g, ' ').includes(q)
) ?? null
)
}
/** Stable key for a collapsed drug result (brand+generic identity). */
function drugKey(d: DrugSearchResult): string {
return `${(d.brand_name ?? '').toLowerCase()}|${(d.generic_name ?? '').toLowerCase()}`
}
/**
* Unified Drug Reference surface.
*
* One search box runs BOTH directions of the symbiotic relationship:
* - drug-name search (a drug → identity) → "Drugs" section
* - situation matching (a situation → its drugs) → "For …" section
* Curated situation chips are always visible for browsing; clicking one searches it.
*
* Empty state (rowCount === 0): the "download FDA drug data" prompt + IngestStatus.
* Once data is loaded: chips + dual-section results, with the FDA-data update control
* and source citation at the foot.
*/
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies = [], remediesEnabled = false }: PageProps) {
const [query, setQuery] = useState('')
const [productType, setProductType] = useState<string | null>(null)
const [route, setRoute] = useState<string | null>(null)
const [sort, setSort] = useState<'relevance' | 'name'>('relevance')
const [remedyKind, setRemedyKind] = useState<'all' | 'herb' | 'self-care'>('all')
// Drug-name results.
const [drugResults, setDrugResults] = useState<DrugSearchResult[]>([])
const [drugLoading, setDrugLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
// Situation results.
const [situation, setSituation] = useState<ConditionSummary | null>(null)
const [situationDrugs, setSituationDrugs] = useState<DrugSearchResult[]>([])
const [situationRemedies, setSituationRemedies] = useState<NaturalRemedy[]>([])
const [situationLoading, setSituationLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [error, setError] = useState<string | null>(null)
const [triggering, setTriggering] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [resetting, setResetting] = useState(false)
const [status, setStatus] = useState<DrugIngestStatus | null>(ingestStatus)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Top-level phase derived from the two sub-phases. `busy` = a phase is running.
const phase = status?.phase ?? 'idle'
const busy = phase === 'downloading' || phase === 'ingesting'
// The manual "Ingest into search" button is available once parts are on disk.
const canIngestFromDisk =
status?.download.state === 'completed' && status?.ingest.state !== 'running'
// Group curated conditions by category, preserving curated order.
const grouped = useMemo(() => {
const map = new Map<string, ConditionSummary[]>()
for (const c of conditions) {
const bucket = map.get(c.category) ?? []
bucket.push(c)
map.set(c.category, bucket)
}
return Array.from(map.entries())
}, [conditions])
/** Drug-name search (one direction). Appends on "Load more". */
const searchDrugs = useCallback(
async (
q: string,
pt: string | null,
rt: string | null,
srt: 'relevance' | 'name',
off: number,
append: boolean
) => {
// The Natural pill routes the by-name direction to the curated herb list
// (client-side, see remedyMatches) — drug_labels isn't queried at all.
if (pt === NATURAL_FILTER || !q.trim()) {
setDrugResults([])
setHasMore(false)
return
}
setDrugLoading(true)
try {
const params = new URLSearchParams({ q, limit: String(LIMIT), offset: String(off) })
if (pt) params.set('product_type', pt)
if (rt) params.set('route', rt)
if (srt && srt !== 'relevance') params.set('sort', srt)
const resp = await fetch(`/api/drug-reference/search?${params}`)
if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
const json = (await resp.json()) as { results: DrugSearchResult[] }
const next = json.results ?? []
setDrugResults(append ? (prev) => [...prev, ...next] : next)
setHasMore(next.length === LIMIT)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setDrugLoading(false)
}
},
[]
)
/**
* Situation search (the other direction). Resolves the query to a curated
* situation (by slug) or free text, fetches that situation's OTC drugs and
* natural remedies (Phase 2).
* Pass an explicit slug (chip / deep link) to force the curated path.
* opts.route and opts.sort are forwarded to the backend so the situation drug
* stack respects the active filter controls.
*/
const searchSituation = useCallback(
async (
q: string,
conds: ConditionSummary[],
forceSlug?: string,
opts?: { route: string | null; sort: 'relevance' | 'name' }
) => {
const matched = forceSlug
? conds.find((c) => c.slug === forceSlug) ?? null
: matchSituation(q, conds)
if (!q.trim() && !forceSlug) {
setSituation(null)
setSituationDrugs([])
setSituationRemedies([])
return
}
setSituationLoading(true)
try {
const params = new URLSearchParams()
if (matched) params.set('slug', matched.slug)
else params.set('q', q)
if (opts?.route) params.set('route', opts.route)
if (opts?.sort && opts.sort !== 'relevance') params.set('sort', opts.sort)
const resp = await fetch(`/api/conditions/drugs?${params}`)
if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
const json = (await resp.json()) as ConditionDrugsResult
setSituation(json.condition ?? matched ?? { slug: '', label: q.trim(), category: 'Search' })
setSituationDrugs(json.drugs ?? [])
setSituationRemedies(json.remedies ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setSituationLoading(false)
}
},
[]
)
/** Run both directions for a query. */
const runSearch = useCallback(
(q: string, pt: string | null, rt: string | null, srt: 'relevance' | 'name') => {
setError(null)
setOffset(0)
setSearched(q.trim().length > 0)
searchDrugs(q, pt, rt, srt, 0, false)
searchSituation(q, conditions, undefined, { route: rt, sort: srt })
},
[conditions, searchDrugs, searchSituation]
)
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => runSearch(val, productType, route, sort), DEBOUNCE_MS)
}
const handleFilterChange = (pt: string | null) => {
setProductType(pt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
// Product-type filter only narrows the drug-name section.
searchDrugs(query, pt, route, sort, 0, false)
}
const handleRouteChange = (rt: string | null) => {
setRoute(rt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
searchDrugs(query, productType, rt, sort, 0, false)
// Re-fetch the active situation with the new route filter.
if (situation) {
const forceSlug = situation.slug || undefined
searchSituation(query, conditions, forceSlug, { route: rt, sort })
}
}
const handleSortChange = (srt: 'relevance' | 'name') => {
setSort(srt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
searchDrugs(query, productType, route, srt, 0, false)
// Re-fetch the active situation with the new sort order.
if (situation) {
const forceSlug = situation.slug || undefined
searchSituation(query, conditions, forceSlug, { route, sort: srt })
}
}
/** Click a chip (or follow a deep link): set the box and search that situation. */
const selectSituation = useCallback(
(c: ConditionSummary) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
setQuery(c.label)
setError(null)
setOffset(0)
setSearched(true)
searchDrugs(c.label, productType, route, sort, 0, false)
searchSituation(c.label, conditions, c.slug)
},
[conditions, productType, route, sort, searchDrugs, searchSituation]
)
// Honor a ?situation= deep link from the drug-detail reverse link, once.
useEffect(() => {
const slug = initialSituationSlug()
if (!slug) return
const target = conditions.find((c) => c.slug === slug)
if (target) selectSituation(target)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleLoadMore = () => {
const newOffset = offset + LIMIT
setOffset(newOffset)
searchDrugs(query, productType, route, sort, newOffset, true)
}
const refreshStatus = async () => {
const statusResp = await fetch('/api/drug-reference/status')
if (statusResp.ok) setStatus(await statusResp.json())
}
const handleTriggerDownload = async () => {
if (triggering) return
setTriggering(true)
try {
const resp = await fetch('/api/drug-reference/download', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setTriggering(false)
}
}
const handleIngestFromDisk = async () => {
if (ingesting) return
setIngesting(true)
try {
const resp = await fetch('/api/drug-reference/ingest', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setIngesting(false)
}
}
// Escape hatch for a wedged ingest: a worker killed mid-ingest (e.g. during an
// upgrade) can leave the job 'active' with a stale lock, which disables the
// normal buttons ("Indexing…") until lockDuration elapses. This force-clears
// that job and restarts ingest from the on-disk parts (no re-download).
const handleResetIngest = async () => {
if (resetting) return
if (
!window.confirm(
'Restart the ingest? This clears the current (possibly stuck) ingest job and ' +
're-runs it from the already-downloaded files.'
)
) {
return
}
setResetting(true)
try {
const resp = await fetch('/api/drug-reference/reset-ingest', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setResetting(false)
}
}
const handleStatusRefresh = async () => {
try {
const resp = await fetch('/api/drug-reference/status')
if (resp.ok) {
const newStatus = (await resp.json()) as DrugIngestStatus
setStatus(newStatus)
if (newStatus.phase === 'ready' && newStatus.rowCount > rowCount) {
router.reload({ only: ['rowCount', 'ingestStatus'] })
}
}
} catch {
// ignore
}
}
const isEmpty = rowCount === 0
const hasQuery = query.trim().length > 0
const loading = drugLoading || situationLoading
// Dedupe: a drug shown in the situation section is suppressed from the drug-name
// section so the same product never appears twice on one screen.
const situationKeys = useMemo(
() => new Set(situationDrugs.map(drugKey)),
[situationDrugs]
)
const dedupedDrugResults = useMemo(
() => drugResults.filter((d) => !situationKeys.has(drugKey(d))),
[drugResults, situationKeys]
)
// Remedy matches for the by-name direction. With the Natural pill active an
// empty box browses the whole curated list; with a query it narrows by
// name/common-names/uses. Under Rx/OTC the user asked for drugs specifically,
// so the remedy block stays out of the way.
const remedyMatches = useMemo(() => {
let base: NaturalRemedy[]
if (productType === NATURAL_FILTER) {
base = query.trim() ? matchRemedies(remedies, query) : remedies
} else if (productType === null) {
base = matchRemedies(remedies, query)
} else {
return []
}
if (remedyKind === 'all') return base
return base.filter((r) => (r.kind ?? 'herb') === remedyKind)
}, [remedies, query, productType, remedyKind])
// When the Natural pill is active, hide the OTC drugs sub-section in the
// situation card (user asked for herbs/self-care only).
const visibleSituationDrugs = productType === NATURAL_FILTER ? [] : situationDrugs
// When a specific remedyKind filter is active, narrow the situation remedies too.
// Show remedies only when Natural pill (NATURAL_FILTER) or All-types (null).
const visibleSituationRemedies = useMemo(() => {
if (productType !== null && productType !== NATURAL_FILTER) return []
if (remedyKind === 'all') return situationRemedies
return situationRemedies.filter((r) => (r.kind ?? 'herb') === remedyKind)
}, [situationRemedies, productType, remedyKind])
const showSituationSection =
situation !== null && (visibleSituationDrugs.length > 0 || visibleSituationRemedies.length > 0)
const showDrugSection = dedupedDrugResults.length > 0
const showRemedySection = remedyMatches.length > 0
const nothingFound =
searched && !loading && !showSituationSection && !showDrugSection && !showRemedySection
return (
<AppLayout>
<Head title="Drug Reference" />
<div className="p-4 max-w-4xl mx-auto">
{/* Header */}
<div className="mb-4">
<div className="flex flex-wrap items-start justify-between gap-3 mb-1">
<h1 className="text-2xl font-bold text-desert-green-darker">Drug Reference</h1>
{rowCount > 0 && (
<Link href="/drug-reference/interactions">
<StyledButton variant="outline" size="sm" onClick={() => {}}>
Compare interactions
</StyledButton>
</Link>
)}
</div>
<p className="text-sm opacity-70">
Search a drug by name, or a situation burn, fever, diarrhea to see the
over-the-counter drugs whose offline FDA labels treat it.{' '}
{rowCount > 0 ? `${rowCount.toLocaleString()} labels.` : ''}
</p>
</div>
{isEmpty ? (
// ── Empty state ────────────────────────────────────────────────────
<div className="border-2 border-dashed border-desert-stone-lighter rounded-2xl p-8 text-center bg-desert-white">
<p className="text-lg font-semibold mb-2 text-desert-green-darker">No FDA drug data yet</p>
<p className="mb-6 opacity-70">
Download the openFDA drug-label dataset to enable offline search. Requires ~1.7 GB
compressed download (~810 GB after ingestion).
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<StyledButton
variant="primary"
onClick={handleTriggerDownload}
disabled={triggering || busy}
>
{phase === 'downloading'
? 'Downloading…'
: phase === 'ingesting'
? 'Indexing…'
: triggering
? 'Starting…'
: 'Download FDA drug data'}
</StyledButton>
{canIngestFromDisk && (
<StyledButton
variant="secondary"
onClick={handleIngestFromDisk}
disabled={ingesting || busy}
>
{ingesting ? 'Starting…' : 'Ingest into search'}
</StyledButton>
)}
{/* Escape hatch — only while ingest appears to be running. Clears a
wedged "Indexing…" state (stale active job from a killed worker)
and restarts from the already-downloaded files. */}
{phase === 'ingesting' && (
<StyledButton
variant="outline"
onClick={handleResetIngest}
disabled={resetting}
>
{resetting ? 'Restarting…' : 'Restart ingest'}
</StyledButton>
)}
</div>
{status && (
<div className="mt-6">
<IngestStatus status={status} onRefresh={handleStatusRefresh} />
</div>
)}
</div>
) : (
// ── Unified search surface ─────────────────────────────────────────
<>
{/* Search box */}
<div className="relative mb-3">
<IconSearch
size={18}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-desert-stone"
/>
<input
type="text"
value={query}
onChange={handleQueryChange}
placeholder="Search a drug or a situation — ibuprofen, heartburn, poison ivy…"
className="w-full rounded-lg border border-desert-stone-lighter bg-white py-2 pl-10 pr-4 text-sm text-desert-green-darker transition focus:border-desert-green focus:outline-none focus:ring-2 focus:ring-desert-green/20"
/>
</div>
{/* OTC / Rx filter pills — narrow the by-name drug section */}
<div className="flex flex-wrap gap-2 mb-6">
<FilterPill active={productType === null} onClick={() => handleFilterChange(null)}>
All
</FilterPill>
<FilterPill
active={productType === PRODUCT_TYPES.OTC}
tone="olive"
onClick={() => handleFilterChange(PRODUCT_TYPES.OTC)}
>
OTC
</FilterPill>
<FilterPill
active={productType === PRODUCT_TYPES.RX}
tone="orange"
onClick={() => handleFilterChange(PRODUCT_TYPES.RX)}
>
Rx
</FilterPill>
{/* Affirmative-content gate (#1040): the "Natural" remedy filter
only appears once remedies are enabled (post clinician-pass). */}
{remediesEnabled && (
<FilterPill
active={productType === NATURAL_FILTER}
tone="olive"
onClick={() => handleFilterChange(NATURAL_FILTER)}
>
Natural
</FilterPill>
)}
{/* Secondary controls: route + sort for the drug-name search, or
herb/self-care sub-filter when Natural is active. */}
{productType === NATURAL_FILTER ? (
<div className="flex items-center gap-2 ml-auto">
{(['all', 'herb', 'self-care'] as const).map((k) => (
<FilterPill key={k} active={remedyKind === k} onClick={() => setRemedyKind(k)}>
{k === 'all' ? 'All kinds' : k === 'herb' ? 'Herbs' : 'Self-care'}
</FilterPill>
))}
</div>
) : (
<div className="flex items-center gap-2 ml-auto">
<select
value={route ?? ''}
onChange={(e) => handleRouteChange(e.target.value || null)}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
aria-label="Filter by administration route"
>
<option value="">Any route</option>
{ROUTE_OPTIONS.map((r) => (
<option key={r} value={r}>
{routeLabel(r)}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => handleSortChange(e.target.value as 'relevance' | 'name')}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
aria-label="Sort drug results"
>
<option value="relevance">Best match</option>
<option value="name">AZ</option>
</select>
</div>
)}
</div>
{error && (
<div className="mb-4 rounded-lg border border-desert-red/30 bg-desert-red/5 p-3 text-sm text-desert-red-dark">
{error}
</div>
)}
{loading && !showSituationSection && !showDrugSection && (
<div className="text-center py-8 opacity-60">Searching</div>
)}
{/* ── Situation section (a situation → its drugs + remedies) ──────── */}
{showSituationSection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
{/* Section header */}
<div className="flex items-center gap-2.5 border-b border-desert-stone-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-olive/10 text-desert-olive-dark">
<IconFirstAidKit size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">
For{' '}
<span className="text-desert-olive-dark">
&ldquo;{situation?.label}&rdquo;
</span>
</h2>
<span className="ml-auto text-xs text-desert-stone">
{visibleSituationDrugs.length > 0 && `${visibleSituationDrugs.length} OTC`}
{visibleSituationDrugs.length > 0 && visibleSituationRemedies.length > 0 && ' · '}
{visibleSituationRemedies.length > 0 && `${visibleSituationRemedies.length} natural`}
</span>
</div>
{/* OTC drugs sub-section */}
{visibleSituationDrugs.length > 0 && (
<>
{visibleSituationRemedies.length > 0 && (
<div className="px-4 py-2 bg-desert-white border-b border-desert-stone-lighter/30">
<span className="text-xs font-semibold text-desert-stone uppercase tracking-wide">
Over-the-counter options
</span>
</div>
)}
<div className="divide-y divide-desert-stone-lighter/40">
{visibleSituationDrugs.map((d) => (
<DrugResultRow key={`sit-${d.id}`} result={d} />
))}
</div>
</>
)}
{/* Natural remedies sub-section */}
{visibleSituationRemedies.length > 0 && (
<div className="border-t border-desert-tan-lighter/40 bg-desert-sand/20">
<div className="flex items-center gap-2 px-4 py-2 border-b border-desert-tan-lighter/30">
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-desert-tan-dark">
<IconLeaf size={14} />
</span>
<span className="text-xs font-semibold text-desert-tan-dark uppercase tracking-wide">
Natural remedies
</span>
</div>
<div className="border-b border-desert-tan-lighter/20 p-3">
<RemedySafetyNote />
</div>
<div className="divide-y divide-desert-tan-lighter/30">
{visibleSituationRemedies.map((r) => (
<SituationRemedyRow key={r.slug} remedy={r} />
))}
</div>
</div>
)}
</section>
)}
{/* ── Drug-name section (a drug → identity) ─────────────────────── */}
{showDrugSection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
<div className="flex items-center gap-2.5 border-b border-desert-stone-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-green/10 text-desert-green">
<IconSearch size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Drugs</h2>
<span className="ml-auto text-xs text-desert-stone">
{dedupedDrugResults.length} match
{dedupedDrugResults.length !== 1 ? 'es' : ''}
</span>
</div>
<div className="divide-y divide-desert-stone-lighter/40">
{dedupedDrugResults.map((d) => (
<DrugResultRow key={`drug-${d.id}`} result={d} />
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-desert-stone-lighter/40 p-3">
<StyledButton variant="secondary" onClick={handleLoadMore} disabled={drugLoading}>
{drugLoading ? 'Loading…' : 'Load more'}
</StyledButton>
</div>
)}
</section>
)}
{/* ── Natural remedies by name (or full browse on the Natural pill) ── */}
{showRemedySection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
<div className="flex items-center gap-2.5 border-b border-desert-tan-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-tan/10 text-desert-tan-dark">
<IconLeaf size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Natural remedies</h2>
<span className="ml-auto text-xs text-desert-stone">
{remedyMatches.length} match{remedyMatches.length !== 1 ? 'es' : ''}
</span>
</div>
<div className="border-b border-desert-tan-lighter/20 p-3">
<RemedySafetyNote />
</div>
<div className="divide-y divide-desert-tan-lighter/30">
{remedyMatches.map((r) => (
<SituationRemedyRow key={`name-${r.slug}`} remedy={r} />
))}
</div>
</section>
)}
{nothingFound && (
<div className="text-center py-8 opacity-60">
No drugs, remedies, or situations match &ldquo;{query}&rdquo;. Try a situation below.
</div>
)}
{/* ── Browse: curated situation chips (always visible) ──────────── */}
<section className={`${CARD_SURFACE} overflow-hidden`}>
<div className="border-b border-desert-stone-lighter/40 bg-gradient-to-b from-desert-sand/50 to-transparent px-5 py-4">
<div className="flex items-center gap-2.5">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-olive/10 text-desert-olive-dark">
<IconFirstAidKit size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Browse by situation</h2>
</div>
<p className="mt-1.5 text-xs text-desert-stone">
{hasQuery
? 'Or pick another situation to see its over-the-counter options.'
: 'Pick a situation to see the over-the-counter drugs whose FDA labels list it.'}
</p>
</div>
<div className="space-y-5 p-5">
<SafetyBanner />
{grouped.map(([category, items]) => (
<div key={category}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-desert-tan-dark">
{category}
</h3>
<div className="flex flex-wrap gap-2">
{items.map((c) => {
const active = situation?.slug === c.slug
return (
<button
key={c.slug}
type="button"
onClick={() => selectSituation(c)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? 'border-desert-olive bg-desert-olive text-white'
: 'border-desert-stone-lighter bg-white text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
}`}
>
{c.label}
</button>
)
})}
</div>
</div>
))}
</div>
</section>
{/* ── FDA data update control ───────────────────────────────────── */}
<div className="mt-8 pt-6 border-t border-desert-stone-lighter/40">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<h3 className="text-sm font-semibold text-desert-green-darker">FDA data</h3>
<div className="flex flex-wrap items-center gap-2">
{canIngestFromDisk && (
<StyledButton
variant="outline"
size="sm"
onClick={handleIngestFromDisk}
disabled={ingesting || busy}
>
{ingesting ? 'Starting…' : 'Ingest into search'}
</StyledButton>
)}
<StyledButton
variant="secondary"
onClick={handleTriggerDownload}
disabled={triggering || busy}
>
{phase === 'downloading'
? 'Downloading…'
: phase === 'ingesting'
? 'Indexing…'
: 'Update FDA data'}
</StyledButton>
</div>
</div>
{status && <IngestStatus status={status} onRefresh={handleStatusRefresh} />}
</div>
</>
)}
{/* ── Source citation (CC0, no-endorsement) ───────────────────────── */}
<footer className="mt-8 pt-4 border-t border-desert-stone-lighter/40 text-xs text-desert-stone">
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA. Label data and situation matches are label-text only; do not rely on
them for medical decisions.
</footer>
</div>
</AppLayout>
)
}
// ─── Situation remedy row (compact, inside the situation card) ────────────────
function SituationRemedyRow({ remedy }: { remedy: NaturalRemedy }) {
return (
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-desert-tan-dark">
{remedy.name}
<span className="ml-2 inline-block rounded-full bg-desert-tan/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-desert-tan-dark align-middle">
{remedy.kind === 'self-care' ? 'Self-care' : 'Herb'}
</span>
</p>
{remedy.commonNames.length > 0 && (
<p className="text-xs text-desert-stone">{remedy.commonNames.join(', ')}</p>
)}
</div>
{/* Plain-text attribution — deliberately NOT a link. The card is fully
self-contained for offline use; nothing on it needs internet. */}
<span className="flex-shrink-0 text-xs text-desert-stone mt-0.5">
Source: {remedySourceName(remedy)}
</span>
</div>
<p className="mt-1.5 text-xs text-desert-green-darker">{remedy.uses}</p>
{remedy.how && (
<p className="mt-1 text-xs text-desert-green-darker">
<strong>How:</strong> {remedy.how}
</p>
)}
{remedy.cautions && (
<p className="mt-1 text-xs text-desert-red-dark">
<strong>Cautions:</strong> {remedy.cautions}
</p>
)}
</div>
)
}
// ─── Filter pill ──────────────────────────────────────────────────────────────
function FilterPill({
active,
tone = 'green',
onClick,
children,
}: {
active: boolean
tone?: 'green' | 'olive' | 'orange'
onClick: () => void
children: React.ReactNode
}) {
const activeClass =
tone === 'orange'
? 'bg-desert-orange text-white border-desert-orange'
: tone === 'olive'
? 'bg-desert-olive text-white border-desert-olive'
: 'bg-desert-green text-white border-desert-green'
const hoverClass =
tone === 'orange'
? 'hover:border-desert-orange'
: tone === 'olive'
? 'hover:border-desert-olive'
: 'hover:border-desert-green'
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? activeClass
: `border-desert-stone-lighter bg-white text-desert-green-darker ${hoverClass}`
}`}
>
{children}
</button>
)
}