| "use client"; |
|
|
| import { useState, useCallback, useEffect, useRef } from "react"; |
| import { Heart } from "lucide-react"; |
| import { useGeoDetect } from "@/lib/hooks/useGeoDetect"; |
| import { ThemeProvider } from "./ThemeProvider"; |
| import { ThemeToggle } from "./ThemeToggle"; |
| import { Sidebar, NavView } from "./chat/Sidebar"; |
| import { RightPanel } from "./chat/RightPanel"; |
| import { NotificationBell } from "./chat/NotificationCenter"; |
| import { ChatView } from "./views/ChatView"; |
| import { HomeView } from "./views/HomeView"; |
| import { EmergencyView } from "./views/EmergencyView"; |
| import { TopicsView } from "./views/TopicsView"; |
| import { SettingsView } from "./views/SettingsView"; |
| import { RecordsView } from "./views/RecordsView"; |
| import { HistoryView } from "./views/HistoryView"; |
| import { MedicationsView } from "./views/MedicationsView"; |
| import { AppointmentsView } from "./views/AppointmentsView"; |
| import { VitalsView } from "./views/VitalsView"; |
| import { HealthDashboard } from "./views/HealthDashboard"; |
| import { ScheduleView } from "./views/ScheduleView"; |
| import { WelcomeScreen } from "./WelcomeScreen"; |
| import { useSettings } from "@/lib/hooks/useSettings"; |
| import { useChat } from "@/lib/hooks/useChat"; |
| import { useHealthStore } from "@/lib/hooks/useHealthStore"; |
| import { useFamilyHealth } from "@/lib/hooks/useFamilyHealth"; |
| import { useNotifications } from "@/lib/hooks/useNotifications"; |
| import { useAuth } from "@/lib/hooks/useAuth"; |
| import { usePasswordResetLink } from "@/lib/hooks/usePasswordResetLink"; |
| import { LoginView } from "./views/LoginView"; |
| import { ProfileView } from "./views/ProfileView"; |
| import { EHRWizard } from "./views/EHRWizard"; |
| import { MyMedicinesView } from "./views/MyMedicinesView"; |
| import { ShareView } from "./views/ShareView"; |
| import { FamilyHealthView } from "./views/FamilyHealthView"; |
| import { DisclaimerBanner } from "./ui/DisclaimerBanner"; |
| import { OfflineBanner } from "./ui/OfflineBanner"; |
| import { InstallPrompt } from "./ui/InstallPrompt"; |
| import { buildPatientContext, todayISO } from "@/lib/health-store"; |
| import { t, type SupportedLanguage } from "@/lib/i18n"; |
|
|
| export default function MedOSApp() { |
| return ( |
| <ThemeProvider> |
| <MedOSAppInner /> |
| </ThemeProvider> |
| ); |
| } |
|
|
| function MedOSAppInner() { |
| const [activeNav, setActiveNav] = useState<NavView>("home"); |
| const settings = useSettings(); |
| const auth = useAuth(); |
| const resetLink = usePasswordResetLink(); |
| const { messages, isTyping, error, sendMessage, clearMessages } = useChat(); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (resetLink) setActiveNav("login"); |
| }, [resetLink]); |
| const health = useHealthStore(auth.token); |
| const family = useFamilyHealth(); |
| const notif = useNotifications(); |
|
|
| |
| |
| const onGeo = useCallback( |
| (g: { country: string; language: any; emergencyNumber: string }) => { |
| settings.applyGeo(g); |
| }, |
| [settings], |
| ); |
| useGeoDetect({ |
| skip: !settings.isLoaded || settings.explicitLanguage, |
| onResult: onGeo, |
| }); |
|
|
| const handleSendMessage = (content: string) => { |
| sendMessage(content, { |
| preset: settings.advancedMode ? undefined : settings.preset, |
| provider: settings.advancedMode ? settings.provider : undefined, |
| |
| |
| apiKey: settings.apiKey, |
| userHfToken: settings.hfToken || undefined, |
| context: { |
| country: settings.country, |
| language: settings.language, |
| emergencyNumber: settings.emergencyNumber, |
| }, |
| }); |
| |
| if (activeNav !== "chat") { |
| setActiveNav("chat"); |
| } |
| }; |
|
|
| const handleStartVoice = () => { |
| setActiveNav("chat"); |
| |
| }; |
|
|
| const handleWelcomeComplete = (lang: SupportedLanguage, country: string) => { |
| |
| |
| settings.setLanguageExplicit(lang); |
| settings.setCountryExplicit(country); |
| settings.setWelcomeCompleted(true); |
| }; |
|
|
| |
| |
| |
| const lastSavedCount = useRef(0); |
| useEffect(() => { |
| const userMsgs = messages.filter((m) => m.role === "user"); |
| if ( |
| userMsgs.length > 0 && |
| messages.length >= 3 && |
| messages.length !== lastSavedCount.current && |
| !isTyping |
| ) { |
| lastSavedCount.current = messages.length; |
| health.saveSession({ |
| date: new Date().toISOString(), |
| preview: userMsgs[0].content.slice(0, 120), |
| messageCount: messages.length, |
| topic: undefined, |
| }); |
| } |
| }, [messages.length, isTyping]); |
|
|
| const handleNavigate = (view: string) => { |
| setActiveNav(view as NavView); |
| }; |
|
|
| |
| const textSizeClass = |
| settings.textSize === "large" |
| ? "text-lg" |
| : settings.textSize === "small" |
| ? "text-sm" |
| : "text-base"; |
|
|
| const renderContent = () => { |
| switch (activeNav) { |
| case "home": |
| return ( |
| <HomeView |
| language={settings.language} |
| country={settings.country} |
| emergencyNumber={settings.emergencyNumber} |
| onNavigate={handleNavigate} |
| onSendMessage={handleSendMessage} |
| onStartVoice={handleStartVoice} |
| /> |
| ); |
| case "emergency": |
| return ( |
| <EmergencyView |
| language={settings.language} |
| emergencyNumber={settings.emergencyNumber} |
| /> |
| ); |
| case "topics": |
| return ( |
| <TopicsView |
| language={settings.language} |
| onSelectTopic={(topic) => handleSendMessage(`Tell me about ${topic}`)} |
| /> |
| ); |
| case "settings": |
| return ( |
| <SettingsView |
| preset={settings.preset} |
| setPreset={settings.setPreset} |
| hfToken={settings.hfToken} |
| setHfToken={settings.setHfToken} |
| clearHfToken={settings.clearHfToken} |
| provider={settings.provider} |
| setProvider={settings.setProvider} |
| apiKey={settings.apiKey} |
| setApiKey={settings.setApiKey} |
| clearApiKey={settings.clearApiKey} |
| advancedMode={settings.advancedMode} |
| setAdvancedMode={settings.setAdvancedMode} |
| language={settings.language} |
| setLanguage={settings.setLanguageExplicit} |
| country={settings.country} |
| setCountry={settings.setCountryExplicit} |
| voiceEnabled={settings.voiceEnabled} |
| setVoiceEnabled={settings.setVoiceEnabled} |
| readAloud={settings.readAloud} |
| setReadAloud={settings.setReadAloud} |
| textSize={settings.textSize} |
| setTextSize={settings.setTextSize} |
| simpleLanguage={settings.simpleLanguage} |
| setSimpleLanguage={settings.setSimpleLanguage} |
| darkMode={settings.darkMode} |
| setDarkMode={settings.setDarkMode} |
| emergencyNumber={settings.emergencyNumber} |
| /> |
| ); |
| case "schedule": |
| return ( |
| <ScheduleView |
| medications={health.medications} |
| medicationLogs={health.medicationLogs} |
| appointments={health.appointments} |
| onMarkMedTaken={health.markMedTaken} |
| isMedTaken={health.isMedTaken} |
| onEditAppointment={health.editAppointment} |
| onNavigate={handleNavigate} |
| language={settings.language} |
| /> |
| ); |
| case "health-dashboard": |
| return ( |
| <HealthDashboard |
| medications={health.medications} |
| medicationLogs={health.medicationLogs} |
| appointments={health.appointments} |
| vitals={health.vitals} |
| records={health.records} |
| onNavigate={handleNavigate} |
| onMarkMedTaken={health.markMedTaken} |
| isMedTaken={health.isMedTaken} |
| getMedStreak={health.getMedStreak} |
| onExport={health.downloadAll} |
| language={settings.language} |
| isAuthenticated={auth.isAuthenticated} |
| /> |
| ); |
| case "medications": |
| return ( |
| <MedicationsView |
| medications={health.medications} |
| onAdd={health.addMedication} |
| onEdit={health.editMedication} |
| onDelete={health.deleteMedication} |
| onMarkTaken={health.markMedTaken} |
| isTaken={health.isMedTaken} |
| getStreak={health.getMedStreak} |
| language={settings.language} |
| /> |
| ); |
| case "appointments": |
| return ( |
| <AppointmentsView |
| appointments={health.appointments} |
| onAdd={health.addAppointment} |
| onEdit={health.editAppointment} |
| onDelete={health.deleteAppointment} |
| language={settings.language} |
| /> |
| ); |
| case "vitals": |
| return ( |
| <VitalsView |
| vitals={health.vitals} |
| onAdd={health.addVital} |
| onDelete={health.deleteVital} |
| language={settings.language} |
| /> |
| ); |
| case "records": |
| return ( |
| <RecordsView |
| records={health.records} |
| onAdd={health.addRecord} |
| onEdit={health.editRecord} |
| onDelete={health.deleteRecord} |
| onExport={health.downloadAll} |
| language={settings.language} |
| /> |
| ); |
| case "my-medicines": |
| return ( |
| <MyMedicinesView |
| medicines={health.medicines} |
| onAdd={health.addMedicine} |
| onUpdate={health.editMedicine} |
| onDelete={health.deleteMedicine} |
| onAddToSchedule={(med) => { |
| // Add to the medication schedule tracker |
| health.addMedication({ |
| name: med.name, |
| dose: med.dose, |
| frequency: "daily", |
| times: ["08:00"], |
| startDate: todayISO(), |
| active: true, |
| }); |
| setActiveNav("medications"); |
| }} |
| language={settings.language} |
| /> |
| ); |
| case "family-health": |
| return ( |
| <FamilyHealthView |
| mode={family.mode} |
| members={family.members} |
| records={family.records} |
| currentMemberId={family.currentMemberId} |
| onSetMode={family.setMode} |
| onSetCurrentMember={family.setCurrentMemberId} |
| onSeedDefaultFamily={family.seedDefaultFamily} |
| onAddMember={family.addMember} |
| onUpdateMember={family.updateMember} |
| onUpsertMonthlyRecord={family.upsertMonthlyRecord} |
| onCreateInvite={family.createInvite} |
| onExport={family.exportData} |
| language={settings.language} |
| /> |
| ); |
| case "share": |
| return <ShareView language={settings.language} />; |
| case "history": |
| return ( |
| <HistoryView |
| history={health.history} |
| onDelete={health.deleteSession} |
| onClearAll={health.clearAllHistory} |
| onReplay={(preview) => handleSendMessage(preview)} |
| language={settings.language} |
| /> |
| ); |
| case "login": |
| case "register": |
| return ( |
| <LoginView |
| initialFlow={ |
| resetLink |
| ? "reset" |
| : activeNav === "register" |
| ? "register" |
| : "login" |
| } |
| initialEmail={resetLink?.email} |
| initialCode={resetLink?.code} |
| onLogin={async (e, p) => { |
| const res = await auth.login(e, p); |
| if (res.ok) setActiveNav("home"); |
| return res; |
| }} |
| onRegister={async (e, p, o) => { |
| const res = await auth.register(e, p, o); |
| if (res.ok && !res.needsVerification) setActiveNav("home"); |
| return res; |
| }} |
| onVerifyEmail={async (code) => { |
| const res = await auth.verifyEmail(code); |
| if (res.ok) setActiveNav("home"); |
| return res; |
| }} |
| onResendVerification={auth.resendVerification} |
| onForgotPassword={auth.forgotPassword} |
| onResetPassword={async (e, c, p) => { |
| const res = await auth.resetPassword(e, c, p); |
| if (res.ok) setActiveNav("home"); |
| return res; |
| }} |
| language={settings.language} |
| /> |
| ); |
| case "ehr-wizard": |
| return ( |
| <EHRWizard |
| onComplete={() => setActiveNav("profile")} |
| onCancel={() => setActiveNav("home")} |
| language={settings.language} |
| /> |
| ); |
| case "profile": |
| return auth.user ? ( |
| <ProfileView |
| user={auth.user} |
| onLogout={() => { |
| auth.logout(); |
| setActiveNav("home"); |
| }} |
| onExport={health.downloadAll} |
| onOpenEHR={() => setActiveNav("ehr-wizard")} |
| onDeleteAccount={async (password, confirmEmail) => { |
| const res = await auth.deleteMe(password, confirmEmail); |
| if (res.ok) { |
| // Server already invalidated the session; useAuth wiped |
| // local token + user. Send the user back to home. |
| setActiveNav("home"); |
| } |
| return res; |
| }} |
| medicationCount={health.medications.length} |
| appointmentCount={health.appointments.length} |
| vitalCount={health.vitals.length} |
| recordCount={health.records.length} |
| language={settings.language} |
| /> |
| ) : ( |
| <LoginView |
| onLogin={async (e, p) => { |
| const res = await auth.login(e, p); |
| if (res.ok) setActiveNav("profile"); |
| return res; |
| }} |
| onRegister={async (e, p, o) => { |
| const res = await auth.register(e, p, o); |
| if (res.ok && !res.needsVerification) setActiveNav("profile"); |
| return res; |
| }} |
| onVerifyEmail={auth.verifyEmail} |
| onResendVerification={auth.resendVerification} |
| onForgotPassword={auth.forgotPassword} |
| onResetPassword={auth.resetPassword} |
| language={settings.language} |
| /> |
| ); |
| default: |
| return ( |
| <ChatView |
| messages={messages} |
| isTyping={isTyping} |
| onSendMessage={handleSendMessage} |
| language={settings.language} |
| emergencyNumber={settings.emergencyNumber} |
| voiceEnabled={settings.voiceEnabled} |
| readAloud={settings.readAloud} |
| onNavigateEmergency={() => setActiveNav("emergency")} |
| /> |
| ); |
| } |
| }; |
|
|
| |
| if (!settings.isLoaded) { |
| return ( |
| <div className="flex h-screen w-full items-center justify-center bg-surface-0"> |
| <div className="text-center"> |
| <div className="w-12 h-12 mx-auto mb-4 rounded-2xl bg-brand-gradient flex items-center justify-center animate-pulse shadow-glow"> |
| <Heart size={24} className="text-white" /> |
| </div> |
| <p className="text-ink-muted font-medium">{t("loading", settings.language)}</p> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| if (!settings.welcomeCompleted) { |
| return ( |
| <WelcomeScreen |
| detectedLanguage={settings.language} |
| detectedCountry={settings.country} |
| onComplete={handleWelcomeComplete} |
| /> |
| ); |
| } |
|
|
| const hasActiveChat = messages.length > 1; |
|
|
| return ( |
| <div |
| className={`relative flex flex-col h-screen-safe w-full font-sans text-ink-base ${textSizeClass}`} |
| > |
| <OfflineBanner /> |
| <InstallPrompt /> |
| <div className="flex flex-1 overflow-hidden"> |
| {/* Sidebar */} |
| <Sidebar |
| activeNav={activeNav} |
| setActiveNav={setActiveNav} |
| language={settings.language} |
| advancedMode={settings.advancedMode} |
| isAuthenticated={auth.isAuthenticated} |
| username={auth.user?.displayName || auth.user?.email} |
| /> |
| |
| {/* Main Content */} |
| <div className="flex-1 flex flex-col relative overflow-hidden pb-14 md:pb-0"> |
| {/* Top Header — clean, mobile-first, always accessible */} |
| <header className="h-14 sm:h-16 bg-surface-1/90 backdrop-blur-xl border-b border-line/50 flex items-center justify-between px-3 sm:px-8 sticky top-0 z-20"> |
| {/* Mobile logo — larger tap target */} |
| <div className="flex items-center gap-2.5 md:hidden"> |
| <div className="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center text-white shadow-soft"> |
| <Heart size={16} /> |
| </div> |
| <span className="font-bold text-ink-base tracking-tight text-base">MedOS</span> |
| </div> |
| |
| <h2 className="hidden md:block font-bold text-lg text-ink-base tracking-tight"> |
| {activeNav === "home" |
| ? t("nav_home", settings.language) |
| : activeNav === "chat" |
| ? t("nav_ask", settings.language) |
| : activeNav === "emergency" |
| ? t("nav_emergency", settings.language) |
| : activeNav === "topics" |
| ? t("nav_topics", settings.language) |
| : activeNav === "settings" |
| ? t("nav_settings", settings.language) |
| : activeNav === "family-health" |
| ? "MedOS Family" |
| : activeNav} |
| </h2> |
| |
| <div className="flex items-center gap-2 sm:gap-3"> |
| <NotificationBell |
| notifications={notif.notifications} |
| count={notif.count} |
| onDismiss={notif.dismiss} |
| onDismissAll={notif.dismissAll} |
| /> |
| <ThemeToggle /> |
| {/* The header used to host a pulsing red EmergencyCTA on every |
| * page. It read as anxious noise on non-emergency screens and |
| * competed with the main actions. Emergency now lives in the |
| * sidebar's Tools group (NavItem with urgent flag) where it |
| * stays one click away without dominating the chrome. The |
| * deterministic safety engine still routes any R5 input to an |
| * emergency template at the chat-route level, regardless of |
| * what UI is visible. */} |
| </div> |
| </header> |
| |
| {/* Dynamic Content Area */} |
| <main className="flex-1 flex relative overflow-hidden"> |
| <div className="flex-1 flex flex-col relative">{renderContent()}</div> |
| </main> |
| </div> |
| |
| {/* Right Panel — only rendered for authenticated users. |
| * |
| * The right rail is for personal health context (Vitals Today, |
| * Upcoming meds + appointments). For guests it offered no value |
| * and competed with the left sidebar's auth card. The whole |
| * component is now gated, eliminating the 'two sidebars feeling' |
| * and the duplicate sign-up prompts. */} |
| {auth.isAuthenticated && ( |
| <RightPanel |
| language={settings.language} |
| emergencyNumber={settings.emergencyNumber} |
| vitals={health.vitals} |
| medications={health.medications} |
| appointments={health.appointments} |
| isMedTaken={health.isMedTaken} |
| onNavigate={handleNavigate} |
| isAuthenticated |
| notificationCount={notif.count} |
| onOpenNotifications={() => {}} |
| /> |
| )} |
| </div> |
| <DisclaimerBanner language={settings.language} /> |
| </div> |
| ); |
| } |
|
|