diff --git a/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx b/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx new file mode 100644 index 0000000..a3a7268 --- /dev/null +++ b/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx @@ -0,0 +1,93 @@ +import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react' +import { IconAlertTriangle } from '@tabler/icons-react' +import StyledButton from '~/components/StyledButton' + +/** + * localStorage key for the Drug Reference disclaimer acknowledgement. Versioned: + * bump the suffix if the disclaimer text changes materially so every browser is + * re-prompted. Per-browser by design — a new browser/device gets the gate again. + */ +export const DRUG_DISCLAIMER_ACK_KEY = 'nomad:drugReferenceDisclaimer:v1' + +export function hasAcknowledgedDrugDisclaimer(): boolean { + if (typeof window === 'undefined') return true + try { + return window.localStorage.getItem(DRUG_DISCLAIMER_ACK_KEY) === 'ack' + } catch { + return false + } +} + +/** + * First-open disclaimer gate for the Drug Reference. Blocks the page until the + * user acknowledges (no backdrop / Escape dismissal). On acknowledgement the + * acceptance is saved to this browser's localStorage so it isn't shown again on + * this browser — other browsers/devices see it on their first open. + */ +export default function DrugDisclaimerModal({ open, onAcknowledge }: { open: boolean; onAcknowledge: () => void }) { + const acknowledge = () => { + try { + window.localStorage.setItem(DRUG_DISCLAIMER_ACK_KEY, 'ack') + } catch { + // Private mode / storage disabled — still let them through for this session. + } + onAcknowledge() + } + + return ( + {}} className="relative z-50"> + +
+
+ +
+ + + + + Before you use the Drug Reference + +
+ +
+

+ This tool shows general health information from official FDA drug labels and + matches symptoms to over-the-counter options. It is provided for information only. +

+
    +
  • + It is not medical advice and not a substitute for a doctor, pharmacist, or nurse. +
  • +
  • + It is not a drug-interaction checker. Always read each product’s full label + and check with a professional before combining medicines. +
  • +
  • + Situation matches come from label text, not clinical recommendations — they can be incomplete or + include products you wouldn’t expect. +
  • +
  • + Always follow the directions on the actual product you have; dosages and warnings + differ between products. +
  • +
  • + In an emergency, or if symptoms are severe, worsening, or you’re unsure,{' '} + contact a medical professional or call emergency services. +
  • +
+

+ Data is from openFDA (U.S. FDA, public domain). NOMAD is not affiliated with or endorsed by the FDA. +

+
+ +
+ + I understand — continue + +
+
+
+
+
+ ) +} diff --git a/admin/inertia/components/drug-reference/DrugResultRow.tsx b/admin/inertia/components/drug-reference/DrugResultRow.tsx index 3ee817b..16ca043 100644 --- a/admin/inertia/components/drug-reference/DrugResultRow.tsx +++ b/admin/inertia/components/drug-reference/DrugResultRow.tsx @@ -4,18 +4,46 @@ import { PRODUCT_TYPES } from '../../../types/drug_reference' interface Props { result: DrugSearchResult + /** + * Inside an ingredient group the active ingredient is already the group header, + * so lead with the brand (product identity) instead of repeating the ingredient. + * Ungrouped (default) leads with the active ingredient — the thing users think in. + */ + brandFirst?: boolean +} + +/** Title-case an UPPERCASE ingredient string (IBUPROFEN → Ibuprofen). */ +function titleCase(s: string): string { + return s + .toLowerCase() + .replace(/\b([a-z])/g, (m) => m.toUpperCase()) } /** - * A single collapsed search result row. - * - * Shows brand name, generic name, OTC/Rx badge, route, and a "N labels" - * chip when more than one set_id collapsed into this result. + * A single collapsed search result row. Leads with the active ingredient by + * default (drug-first), or the brand when rendered inside an ingredient group. */ -export default function DrugResultRow({ result }: Props) { +export default function DrugResultRow({ result, brandFirst = false }: Props) { const isRx = result.product_type === PRODUCT_TYPES.RX const isOtc = result.product_type === PRODUCT_TYPES.OTC + const ingredient = result.generic_name ? titleCase(result.generic_name) : null + const brand = result.brand_name ?? null + + // Headline vs sub-line depending on context. + const headline = brandFirst + ? (brand ?? ingredient ?? 'Unknown') + : (ingredient ?? brand ?? 'Unknown') + const subParts: string[] = [] + if (brandFirst) { + if (result.manufacturer) subParts.push(result.manufacturer) + } else { + // Ingredient-first: show the brand (if it differs from the ingredient) + maker. + if (brand && brand.toLowerCase() !== (ingredient ?? '').toLowerCase()) subParts.push(brand) + if (result.manufacturer) subParts.push(result.manufacturer) + } + if (result.route) subParts.push(titleCase(result.route)) + return (
- {result.brand_name ?? result.generic_name ?? 'Unknown'} + {headline} - {/* OTC / Rx badge */} {isRx && ( Rx @@ -39,7 +66,6 @@ export default function DrugResultRow({ result }: Props) { )} - {/* Collapsed labels count */} {result.labelCount > 1 && ( {result.labelCount} labels @@ -47,15 +73,15 @@ export default function DrugResultRow({ result }: Props) { )}
-
- {result.brand_name && result.generic_name && ( - {result.generic_name} - )} - {result.manufacturer && ( - {result.manufacturer} - )} - {result.route && {result.route}} -
+ {subParts.length > 0 && ( +
+ {subParts.map((p, i) => ( + + {p} + + ))} +
+ )} diff --git a/admin/inertia/components/drug-reference/IngredientGroup.tsx b/admin/inertia/components/drug-reference/IngredientGroup.tsx new file mode 100644 index 0000000..777a498 --- /dev/null +++ b/admin/inertia/components/drug-reference/IngredientGroup.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { IconChevronDown } from '@tabler/icons-react' +import DrugResultRow from './DrugResultRow' +import type { DrugSearchResult } from '../../../types/drug_reference' +import { PRODUCT_TYPES } from '../../../types/drug_reference' + +export interface IngredientGrouping { + key: string + /** Display name (title-cased ingredient list). */ + label: string + /** True when this group is a single active ingredient (ranked above combos). */ + single: boolean + products: DrugSearchResult[] +} + +/** + * A collapsible "active ingredient → its products" group for the drug-name search. + * A single-product group renders as a plain (ingredient-first) row; multi-product + * groups collapse behind the ingredient name so the list stays scannable. + */ +export default function IngredientGroup({ + group, + defaultOpen = false, +}: { + group: IngredientGrouping + /** Expand this group on first render (used for the top/first result group). */ + defaultOpen?: boolean +}) { + const [open, setOpen] = useState(defaultOpen) + + if (group.products.length === 1) { + return + } + + const anyOtc = group.products.some((p) => p.product_type === PRODUCT_TYPES.OTC) + const anyRx = group.products.some((p) => p.product_type === PRODUCT_TYPES.RX) + + return ( +
+ + {open && ( +
+ {group.products.map((d) => ( + + ))} +
+ )} +
+ ) +} diff --git a/admin/inertia/layouts/AppLayout.tsx b/admin/inertia/layouts/AppLayout.tsx index c660d38..d0ac37e 100644 --- a/admin/inertia/layouts/AppLayout.tsx +++ b/admin/inertia/layouts/AppLayout.tsx @@ -8,7 +8,18 @@ import { Link, router } from '@inertiajs/react' import { IconArrowLeft } from '@tabler/icons-react' import classNames from 'classnames' -export default function AppLayout({ children }: { children: React.ReactNode }) { +export default function AppLayout({ + children, + compact = false, +}: { + children: React.ReactNode + /** + * Compact header for focused tool pages (e.g. Drug Reference): a small inline + * logo + title instead of the full-height branding block, so the tool's own + * controls sit near the top of the viewport instead of ~230px down. + */ + compact?: boolean +}) { const [isChatOpen, setIsChatOpen] = useState(false) const aiAssistantInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA) @@ -16,22 +27,42 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
{ window.location.pathname !== '/home' && ( - +

Back to Home

)}
router.visit('/home')} > - Project NOMAD Logo -

Command Center

+ Project NOMAD Logo +

+ Command Center +


{children}