github-actions[bot] commited on
Commit
18e4712
·
0 Parent(s):

Deploy MedOS Global from b5eca093

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +19 -0
  2. Dockerfile +60 -0
  3. README.md +95 -0
  4. app/admin/page.tsx +327 -0
  5. app/api/admin/stats/route.ts +46 -0
  6. app/api/admin/users/route.ts +78 -0
  7. app/api/auth/forgot-password/route.ts +44 -0
  8. app/api/auth/login/route.ts +51 -0
  9. app/api/auth/logout/route.ts +14 -0
  10. app/api/auth/me/route.ts +31 -0
  11. app/api/auth/register/route.ts +57 -0
  12. app/api/auth/resend-verification/route.ts +28 -0
  13. app/api/auth/reset-password/route.ts +63 -0
  14. app/api/auth/verify-email/route.ts +58 -0
  15. app/api/chat-history/route.ts +95 -0
  16. app/api/chat/route.ts +129 -0
  17. app/api/geo/route.ts +142 -0
  18. app/api/health-data/route.ts +113 -0
  19. app/api/health-data/sync/route.ts +75 -0
  20. app/api/health/route.ts +10 -0
  21. app/api/models/route.ts +14 -0
  22. app/api/og/route.tsx +207 -0
  23. app/api/rag/route.ts +30 -0
  24. app/api/sessions/route.ts +70 -0
  25. app/api/triage/route.ts +29 -0
  26. app/globals.css +397 -0
  27. app/layout.tsx +257 -0
  28. app/page.tsx +5 -0
  29. app/robots.ts +23 -0
  30. app/sitemap.ts +27 -0
  31. app/stats/page.tsx +245 -0
  32. app/symptoms/[slug]/page.tsx +237 -0
  33. app/symptoms/page.tsx +86 -0
  34. components/MedOSGlobalApp.tsx +317 -0
  35. components/chat/MessageBubble.tsx +281 -0
  36. components/chat/QuickChips.tsx +30 -0
  37. components/chat/RightPanel.tsx +134 -0
  38. components/chat/Sidebar.tsx +278 -0
  39. components/chat/TrustBar.tsx +27 -0
  40. components/chat/TypingIndicator.tsx +15 -0
  41. components/chat/VoiceInput.tsx +88 -0
  42. components/mobile/BottomNav.tsx +65 -0
  43. components/mobile/InstallPrompt.tsx +71 -0
  44. components/ui/DisclaimerBanner.tsx +27 -0
  45. components/ui/InstallPrompt.tsx +122 -0
  46. components/ui/OfflineBanner.tsx +33 -0
  47. components/ui/ShareButtons.tsx +59 -0
  48. components/ui/ThemeToggle.tsx +44 -0
  49. components/views/AboutView.tsx +118 -0
  50. components/views/ChatView.tsx +261 -0
.env.example ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # MedOS HuggingFace Space — full backend configuration
3
+ # ============================================================
4
+
5
+ # --- LLM providers ---
6
+ OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space
7
+ OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here
8
+ HF_TOKEN=hf_your-token-here
9
+ DEFAULT_MODEL=free-best
10
+
11
+ # --- Database (SQLite, persistent on HF Spaces /data/) ---
12
+ DB_PATH=/data/medos.db
13
+
14
+ # --- CORS (comma-separated Vercel frontend origins) ---
15
+ ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000
16
+
17
+ # --- Application ---
18
+ NODE_ENV=production
19
+ PORT=7860
Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage production Dockerfile for MedOS Global on HF Spaces
2
+ # Optimized for Next.js 14 standalone output
3
+
4
+ # Stage 1: Install ALL dependencies (including devDeps for build)
5
+ # better-sqlite3 needs python3 + make + g++ to compile the native addon.
6
+ FROM node:18-alpine AS deps
7
+ WORKDIR /app
8
+ RUN apk add --no-cache python3 make g++
9
+ COPY package.json ./
10
+ RUN npm install --legacy-peer-deps && npm cache clean --force
11
+
12
+ # Stage 2: Build the application
13
+ FROM node:18-alpine AS builder
14
+ WORKDIR /app
15
+ COPY --from=deps /app/node_modules ./node_modules
16
+ COPY . .
17
+
18
+ ENV NEXT_TELEMETRY_DISABLED=1
19
+ ENV NODE_ENV=production
20
+
21
+ RUN npm run build
22
+
23
+ # Stage 3: Production runner (lean image)
24
+ FROM node:18-alpine AS runner
25
+ WORKDIR /app
26
+
27
+ ENV NODE_ENV=production
28
+ ENV NEXT_TELEMETRY_DISABLED=1
29
+ # HF Spaces REQUIRES port 7860
30
+ ENV PORT=7860
31
+ ENV HOSTNAME=0.0.0.0
32
+
33
+ RUN addgroup --system --gid 1001 nodejs && \
34
+ adduser --system --uid 1001 nextjs
35
+
36
+ # Copy public assets
37
+ COPY --from=builder /app/public ./public
38
+
39
+ # Copy standalone build (includes server + minimal node_modules)
40
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
41
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
42
+
43
+ # Copy data files (health topics, medical KB)
44
+ COPY --from=builder --chown=nextjs:nodejs /app/data ./data
45
+
46
+ # Persistent storage directory for SQLite DB.
47
+ # HF Spaces mounts /data as persistent volume — the DB survives restarts.
48
+ # Ensure the directory exists and is writable by the nextjs user.
49
+ RUN mkdir -p /data && chown nextjs:nodejs /data
50
+
51
+ ENV DB_PATH=/data/medos.db
52
+
53
+ USER nextjs
54
+
55
+ EXPOSE 7860
56
+
57
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
58
+ CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1
59
+
60
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "MediBot: Free AI Medical Assistant · 20 languages"
3
+ emoji: "\U0001F3E5"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ license: apache-2.0
10
+ short_description: "Free AI medical chatbot. 20 languages. No sign-up."
11
+ tags:
12
+ - medical
13
+ - healthcare
14
+ - chatbot
15
+ - medical-ai
16
+ - health-assistant
17
+ - symptom-checker
18
+ - telemedicine
19
+ - who-guidelines
20
+ - cdc
21
+ - multilingual
22
+ - i18n
23
+ - rag
24
+ - llama-3.3
25
+ - llama-3.3-70b
26
+ - mixtral
27
+ - groq
28
+ - huggingface-inference
29
+ - pwa
30
+ - offline-first
31
+ - free
32
+ - no-signup
33
+ - privacy-first
34
+ - worldwide
35
+ - nextjs
36
+ - docker
37
+ models:
38
+ - meta-llama/Llama-3.3-70B-Instruct
39
+ - meta-llama/Meta-Llama-3-8B-Instruct
40
+ - mistralai/Mixtral-8x7B-Instruct-v0.1
41
+ - Qwen/Qwen2.5-72B-Instruct
42
+ - deepseek-ai/DeepSeek-V3
43
+ - ruslanmv/Medical-Llama3-8B
44
+ - google/gemma-2-9b-it
45
+ datasets:
46
+ - ruslanmv/ai-medical-chatbot
47
+ ---
48
+
49
+ # MediBot — free AI medical assistant, worldwide
50
+
51
+ > **Tell MediBot what's bothering you. In your language. Instantly. For free.**
52
+ > No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines.
53
+
54
+ [![Try MediBot](https://img.shields.io/badge/Try_MediBot-Free_on_HuggingFace-blue?style=for-the-badge&logo=huggingface)](https://huggingface.co/spaces/ruslanmv/MediBot)
55
+ [![Languages](https://img.shields.io/badge/languages-20-14B8A6?style=for-the-badge)](#)
56
+ [![Free](https://img.shields.io/badge/price-free_forever-22C55E?style=for-the-badge)](#)
57
+ [![No sign-up](https://img.shields.io/badge/account-not_required-3B82F6?style=for-the-badge)](#)
58
+
59
+ ## Why MediBot
60
+
61
+ - **Free forever.** No API key, no sign-up, no paywall, no ads.
62
+ - **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands.
63
+ - **Worldwide.** IP-based country detection picks your local emergency number (190+ countries) and adapts the answer to your region (°C/°F, metric/imperial, local guidance).
64
+ - **Best free LLM on HuggingFace.** Powered by **Llama 3.3 70B via HF Inference Providers (Groq)** — fastest high-quality free tier available — with an automatic fallback chain across Cerebras, SambaNova, Together, and Mixtral.
65
+ - **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance.
66
+ - **Red-flag triage.** Built-in symptom patterns detect cardiac, neurological, respiratory, obstetric, pediatric, and mental-health emergencies in every supported language — and immediately escalate to the local emergency number.
67
+ - **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback.
68
+ - **Shareable.** Every AI answer gets a Share button that generates a clean deep link with a branded OG card preview — perfect for WhatsApp, Twitter, and Telegram.
69
+ - **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only.
70
+ - **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot)
71
+
72
+ ## How it works
73
+
74
+ 1. You type (or speak) a health question
75
+ 2. MedOS checks for emergency red flags first
76
+ 3. It searches a medical knowledge base for relevant context
77
+ 4. Your question + context go to **Llama 3.3 70B** (via Groq, free)
78
+ 5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor
79
+
80
+ If the main model is busy, MedOS automatically tries other free models until one responds.
81
+
82
+ ## Built with
83
+
84
+ | Layer | Technology |
85
+ |---|---|
86
+ | Frontend | Next.js 14, React, Tailwind CSS |
87
+ | AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) |
88
+ | Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ |
89
+ | Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset |
90
+ | Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) |
91
+ | Hosting | HuggingFace Spaces (Docker) |
92
+
93
+ ## License
94
+
95
+ Apache 2.0 — free to use, modify, and distribute.
app/admin/page.tsx ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import {
5
+ Users,
6
+ Activity,
7
+ Database,
8
+ MessageCircle,
9
+ Shield,
10
+ Search,
11
+ Trash2,
12
+ ChevronLeft,
13
+ ChevronRight,
14
+ RefreshCw,
15
+ LogIn,
16
+ Lock,
17
+ } from 'lucide-react';
18
+
19
+ interface Stats {
20
+ totalUsers: number;
21
+ verifiedUsers: number;
22
+ adminUsers: number;
23
+ totalHealthData: number;
24
+ totalChats: number;
25
+ activeSessions: number;
26
+ healthBreakdown: Array<{ type: string; count: number }>;
27
+ registrations: Array<{ day: string; count: number }>;
28
+ }
29
+
30
+ interface UserRow {
31
+ id: string;
32
+ email: string;
33
+ displayName: string | null;
34
+ emailVerified: boolean;
35
+ isAdmin: boolean;
36
+ createdAt: string;
37
+ healthDataCount: number;
38
+ chatHistoryCount: number;
39
+ }
40
+
41
+ /**
42
+ * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space.
43
+ * Not linked from the public UI. Requires admin login.
44
+ */
45
+ export default function AdminPage() {
46
+ const [token, setToken] = useState('');
47
+ const [loggedIn, setLoggedIn] = useState(false);
48
+ const [email, setEmail] = useState('');
49
+ const [password, setPassword] = useState('');
50
+ const [loginError, setLoginError] = useState('');
51
+ const [stats, setStats] = useState<Stats | null>(null);
52
+ const [users, setUsers] = useState<UserRow[]>([]);
53
+ const [total, setTotal] = useState(0);
54
+ const [page, setPage] = useState(1);
55
+ const [search, setSearch] = useState('');
56
+ const [loading, setLoading] = useState(false);
57
+
58
+ const headers = useCallback(
59
+ () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }),
60
+ [token],
61
+ );
62
+
63
+ const fetchStats = useCallback(async () => {
64
+ const res = await fetch('/api/admin/stats', { headers: headers() });
65
+ if (res.ok) setStats(await res.json());
66
+ else if (res.status === 403) { setLoggedIn(false); setToken(''); }
67
+ }, [headers]);
68
+
69
+ const fetchUsers = useCallback(async () => {
70
+ setLoading(true);
71
+ const qs = new URLSearchParams({ page: String(page), limit: '20' });
72
+ if (search) qs.set('search', search);
73
+ const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ setUsers(data.users);
77
+ setTotal(data.total);
78
+ }
79
+ setLoading(false);
80
+ }, [headers, page, search]);
81
+
82
+ useEffect(() => {
83
+ if (!loggedIn) return;
84
+ fetchStats();
85
+ fetchUsers();
86
+ }, [loggedIn, fetchStats, fetchUsers]);
87
+
88
+ const handleLogin = async () => {
89
+ setLoginError('');
90
+ const res = await fetch('/api/auth/login', {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ email, password }),
94
+ });
95
+ const data = await res.json();
96
+ if (!res.ok) { setLoginError(data.error || 'Login failed'); return; }
97
+ // Verify this user is actually an admin.
98
+ const meRes = await fetch('/api/auth/me', {
99
+ headers: { Authorization: `Bearer ${data.token}` },
100
+ });
101
+ const me = await meRes.json();
102
+ if (!me.user) { setLoginError('Auth failed'); return; }
103
+ // Check admin flag by trying the admin API.
104
+ const adminCheck = await fetch('/api/admin/stats', {
105
+ headers: { Authorization: `Bearer ${data.token}` },
106
+ });
107
+ if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; }
108
+ setToken(data.token);
109
+ setLoggedIn(true);
110
+ };
111
+
112
+ const handleDeleteUser = async (userId: string, userEmail: string) => {
113
+ if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return;
114
+ await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() });
115
+ fetchUsers();
116
+ fetchStats();
117
+ };
118
+
119
+ // Login screen
120
+ if (!loggedIn) {
121
+ return (
122
+ <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
123
+ <div className="w-full max-w-sm">
124
+ <div className="text-center mb-8">
125
+ <div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
126
+ <Lock size={24} className="text-white" />
127
+ </div>
128
+ <h1 className="text-2xl font-bold text-slate-100">Admin Panel</h1>
129
+ <p className="text-sm text-slate-400 mt-1">MedOS server administration</p>
130
+ </div>
131
+ {loginError && (
132
+ <div className="mb-4 p-3 rounded-xl bg-red-950/50 border border-red-700/50 text-sm text-red-300">
133
+ {loginError}
134
+ </div>
135
+ )}
136
+ <div className="space-y-3">
137
+ <input
138
+ type="email"
139
+ value={email}
140
+ onChange={(e) => setEmail(e.target.value)}
141
+ placeholder="Admin email"
142
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50"
143
+ />
144
+ <input
145
+ type="password"
146
+ value={password}
147
+ onChange={(e) => setPassword(e.target.value)}
148
+ placeholder="Password"
149
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50"
150
+ onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
151
+ />
152
+ <button
153
+ onClick={handleLogin}
154
+ className="w-full py-3 bg-gradient-to-br from-red-500 to-orange-500 text-white rounded-xl font-bold text-sm hover:brightness-110 transition-all flex items-center justify-center gap-2"
155
+ >
156
+ <LogIn size={16} />
157
+ Sign in as Admin
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ const totalPages = Math.ceil(total / 20);
166
+
167
+ return (
168
+ <div className="min-h-screen bg-slate-950 text-slate-100">
169
+ <header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
170
+ <div className="flex items-center gap-3">
171
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
172
+ <Shield size={16} className="text-white" />
173
+ </div>
174
+ <h1 className="font-bold text-lg">MedOS Admin</h1>
175
+ </div>
176
+ <button
177
+ onClick={() => { fetchStats(); fetchUsers(); }}
178
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 text-slate-300 text-xs font-semibold hover:bg-slate-700"
179
+ >
180
+ <RefreshCw size={12} /> Refresh
181
+ </button>
182
+ </header>
183
+
184
+ <main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
185
+ {/* Stats grid */}
186
+ {stats && (
187
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
188
+ <Stat icon={Users} label="Total users" value={stats.totalUsers} />
189
+ <Stat icon={Shield} label="Verified" value={stats.verifiedUsers} />
190
+ <Stat icon={Shield} label="Admins" value={stats.adminUsers} color="text-red-400" />
191
+ <Stat icon={Database} label="Health records" value={stats.totalHealthData} />
192
+ <Stat icon={MessageCircle} label="Conversations" value={stats.totalChats} />
193
+ <Stat icon={Activity} label="Active sessions" value={stats.activeSessions} />
194
+ </div>
195
+ )}
196
+
197
+ {/* Health data breakdown */}
198
+ {stats && stats.healthBreakdown.length > 0 && (
199
+ <div className="mb-8 p-4 rounded-2xl bg-slate-900 border border-slate-800">
200
+ <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Health data by type</h3>
201
+ <div className="flex flex-wrap gap-2">
202
+ {stats.healthBreakdown.map((b) => (
203
+ <span key={b.type} className="px-3 py-1.5 rounded-full bg-slate-800 text-sm font-medium text-slate-200">
204
+ {b.type}: <strong>{b.count}</strong>
205
+ </span>
206
+ ))}
207
+ </div>
208
+ </div>
209
+ )}
210
+
211
+ {/* User management */}
212
+ <div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden">
213
+ <div className="p-4 border-b border-slate-800 flex items-center gap-3">
214
+ <h2 className="font-bold">Users ({total})</h2>
215
+ <div className="flex-1 relative ml-4">
216
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
217
+ <input
218
+ value={search}
219
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
220
+ placeholder="Search by email or name..."
221
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-red-500/50"
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ <div className="overflow-x-auto">
227
+ <table className="w-full text-sm">
228
+ <thead>
229
+ <tr className="text-xs text-slate-400 uppercase tracking-wider border-b border-slate-800">
230
+ <th className="text-left px-4 py-3">User</th>
231
+ <th className="text-center px-4 py-3">Verified</th>
232
+ <th className="text-center px-4 py-3">Role</th>
233
+ <th className="text-center px-4 py-3">Health</th>
234
+ <th className="text-center px-4 py-3">Chats</th>
235
+ <th className="text-left px-4 py-3">Joined</th>
236
+ <th className="text-right px-4 py-3">Actions</th>
237
+ </tr>
238
+ </thead>
239
+ <tbody>
240
+ {users.map((u) => (
241
+ <tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
242
+ <td className="px-4 py-3">
243
+ <div className="font-medium text-slate-100">{u.displayName || '—'}</div>
244
+ <div className="text-xs text-slate-400">{u.email}</div>
245
+ </td>
246
+ <td className="px-4 py-3 text-center">
247
+ <span className={`text-xs font-bold ${u.emailVerified ? 'text-emerald-400' : 'text-slate-500'}`}>
248
+ {u.emailVerified ? 'Yes' : 'No'}
249
+ </span>
250
+ </td>
251
+ <td className="px-4 py-3 text-center">
252
+ {u.isAdmin ? (
253
+ <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30">
254
+ ADMIN
255
+ </span>
256
+ ) : (
257
+ <span className="text-xs text-slate-500">User</span>
258
+ )}
259
+ </td>
260
+ <td className="px-4 py-3 text-center text-slate-300">{u.healthDataCount}</td>
261
+ <td className="px-4 py-3 text-center text-slate-300">{u.chatHistoryCount}</td>
262
+ <td className="px-4 py-3 text-slate-400 text-xs">
263
+ {new Date(u.createdAt).toLocaleDateString()}
264
+ </td>
265
+ <td className="px-4 py-3 text-right">
266
+ {!u.isAdmin && (
267
+ <button
268
+ onClick={() => handleDeleteUser(u.id, u.email)}
269
+ className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
270
+ title="Delete user"
271
+ >
272
+ <Trash2 size={14} />
273
+ </button>
274
+ )}
275
+ </td>
276
+ </tr>
277
+ ))}
278
+ {users.length === 0 && (
279
+ <tr>
280
+ <td colSpan={7} className="px-4 py-8 text-center text-slate-500">
281
+ {loading ? 'Loading...' : 'No users found'}
282
+ </td>
283
+ </tr>
284
+ )}
285
+ </tbody>
286
+ </table>
287
+ </div>
288
+
289
+ {/* Pagination */}
290
+ {totalPages > 1 && (
291
+ <div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
292
+ <span className="text-xs text-slate-400">
293
+ Page {page} of {totalPages}
294
+ </span>
295
+ <div className="flex gap-1">
296
+ <button
297
+ onClick={() => setPage(Math.max(1, page - 1))}
298
+ disabled={page <= 1}
299
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
300
+ >
301
+ <ChevronLeft size={14} />
302
+ </button>
303
+ <button
304
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
305
+ disabled={page >= totalPages}
306
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
307
+ >
308
+ <ChevronRight size={14} />
309
+ </button>
310
+ </div>
311
+ </div>
312
+ )}
313
+ </div>
314
+ </main>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) {
320
+ return (
321
+ <div className="p-4 rounded-xl bg-slate-900 border border-slate-800">
322
+ <Icon size={16} className={color || 'text-slate-400'} />
323
+ <div className="text-2xl font-black mt-2">{value.toLocaleString()}</div>
324
+ <div className="text-[11px] text-slate-500 font-semibold">{label}</div>
325
+ </div>
326
+ );
327
+ }
app/api/admin/stats/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/stats — aggregate platform statistics (admin only).
7
+ */
8
+ export async function GET(req: Request) {
9
+ const admin = requireAdmin(req);
10
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
11
+
12
+ const db = getDb();
13
+
14
+ const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c;
15
+ const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c;
16
+ const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c;
17
+ const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c;
18
+ const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c;
19
+ const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c;
20
+
21
+ // Health data breakdown by type.
22
+ const healthBreakdown = db
23
+ .prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC')
24
+ .all() as any[];
25
+
26
+ // Registrations over time (last 30 days).
27
+ const registrations = db
28
+ .prepare(
29
+ `SELECT date(created_at) as day, COUNT(*) as count
30
+ FROM users
31
+ WHERE created_at > datetime('now', '-30 days')
32
+ GROUP BY day ORDER BY day`,
33
+ )
34
+ .all() as any[];
35
+
36
+ return NextResponse.json({
37
+ totalUsers,
38
+ verifiedUsers,
39
+ adminUsers,
40
+ totalHealthData,
41
+ totalChats,
42
+ activeSessions,
43
+ healthBreakdown,
44
+ registrations,
45
+ });
46
+ }
app/api/admin/users/route.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/users — list all registered users (admin only).
7
+ * Query params: ?page=1&limit=50&search=term
8
+ */
9
+ export async function GET(req: Request) {
10
+ const admin = requireAdmin(req);
11
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
12
+
13
+ const url = new URL(req.url);
14
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10));
15
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)));
16
+ const search = url.searchParams.get('search')?.trim();
17
+ const offset = (page - 1) * limit;
18
+
19
+ const db = getDb();
20
+
21
+ const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : "";
22
+ const params = search ? [`%${search}%`, `%${search}%`] : [];
23
+
24
+ const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c;
25
+
26
+ const rows = db
27
+ .prepare(
28
+ `SELECT id, email, display_name, email_verified, is_admin, created_at
29
+ FROM users ${where}
30
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
31
+ )
32
+ .all(...params, limit, offset) as any[];
33
+
34
+ // Health data count per user.
35
+ const users = rows.map((r) => {
36
+ const healthCount = (
37
+ db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any
38
+ ).c;
39
+ const chatCount = (
40
+ db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any
41
+ ).c;
42
+ return {
43
+ id: r.id,
44
+ email: r.email,
45
+ displayName: r.display_name,
46
+ emailVerified: !!r.email_verified,
47
+ isAdmin: !!r.is_admin,
48
+ createdAt: r.created_at,
49
+ healthDataCount: healthCount,
50
+ chatHistoryCount: chatCount,
51
+ };
52
+ });
53
+
54
+ return NextResponse.json({ users, total, page, limit });
55
+ }
56
+
57
+ /**
58
+ * DELETE /api/admin/users?id=<userId> — delete a user (admin only).
59
+ * CASCADE deletes all their health data, chat history, and sessions.
60
+ */
61
+ export async function DELETE(req: Request) {
62
+ const admin = requireAdmin(req);
63
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
64
+
65
+ const url = new URL(req.url);
66
+ const userId = url.searchParams.get('id');
67
+ if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
68
+
69
+ // Prevent deleting yourself.
70
+ if (userId === admin.id) {
71
+ return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 });
72
+ }
73
+
74
+ const db = getDb();
75
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
76
+
77
+ return NextResponse.json({ success: true });
78
+ }
app/api/auth/forgot-password/route.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genVerificationCode, resetExpiry } from '@/lib/db';
4
+ import { sendPasswordResetEmail } from '@/lib/email';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ });
9
+
10
+ /**
11
+ * POST /api/auth/forgot-password — sends a reset code to the user's email.
12
+ *
13
+ * Always returns 200 even if the email doesn't exist (prevents email enumeration).
14
+ */
15
+ export async function POST(req: Request) {
16
+ try {
17
+ const body = await req.json();
18
+ const { email } = Schema.parse(body);
19
+
20
+ const db = getDb();
21
+ const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any;
22
+
23
+ if (user) {
24
+ const code = genVerificationCode();
25
+ db.prepare(
26
+ `UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now')
27
+ WHERE id = ?`,
28
+ ).run(code, resetExpiry(), user.id);
29
+
30
+ await sendPasswordResetEmail(user.email, code);
31
+ }
32
+
33
+ // Always return success to prevent email enumeration.
34
+ return NextResponse.json({
35
+ message: 'If that email is registered, a reset code has been sent.',
36
+ });
37
+ } catch (error: any) {
38
+ if (error instanceof z.ZodError) {
39
+ return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
40
+ }
41
+ console.error('[Auth ForgotPassword]', error?.message);
42
+ return NextResponse.json({ error: 'Request failed' }, { status: 500 });
43
+ }
44
+ }
app/api/auth/login/route.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ password: z.string().min(1),
9
+ });
10
+
11
+ export async function POST(req: Request) {
12
+ try {
13
+ const body = await req.json();
14
+ const { email, password } = Schema.parse(body);
15
+
16
+ const db = getDb();
17
+ pruneExpiredSessions();
18
+
19
+ const user = db
20
+ .prepare('SELECT id, email, password, display_name, email_verified, is_admin FROM users WHERE email = ?')
21
+ .get(email.toLowerCase()) as any;
22
+
23
+ if (!user || !bcrypt.compareSync(password, user.password)) {
24
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
25
+ }
26
+
27
+ const token = genToken();
28
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
29
+ token,
30
+ user.id,
31
+ sessionExpiry(),
32
+ );
33
+
34
+ return NextResponse.json({
35
+ user: {
36
+ id: user.id,
37
+ email: user.email,
38
+ displayName: user.display_name,
39
+ emailVerified: !!user.email_verified,
40
+ isAdmin: !!user.is_admin,
41
+ },
42
+ token,
43
+ });
44
+ } catch (error: any) {
45
+ if (error instanceof z.ZodError) {
46
+ return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
47
+ }
48
+ console.error('[Auth Login]', error?.message);
49
+ return NextResponse.json({ error: 'Login failed' }, { status: 500 });
50
+ }
51
+ }
app/api/auth/logout/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+
4
+ export async function POST(req: Request) {
5
+ const h = req.headers.get('authorization');
6
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
7
+
8
+ if (token) {
9
+ const db = getDb();
10
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
11
+ }
12
+
13
+ return NextResponse.json({ success: true });
14
+ }
app/api/auth/me/route.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb, pruneExpiredSessions } from '@/lib/db';
3
+
4
+ export async function GET(req: Request) {
5
+ const h = req.headers.get('authorization');
6
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
7
+ if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
8
+
9
+ const db = getDb();
10
+ pruneExpiredSessions();
11
+
12
+ const row = db
13
+ .prepare(
14
+ `SELECT u.id, u.email, u.display_name, u.email_verified, u.created_at
15
+ FROM sessions s JOIN users u ON u.id = s.user_id
16
+ WHERE s.token = ? AND s.expires_at > datetime('now')`,
17
+ )
18
+ .get(token) as any;
19
+
20
+ if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 });
21
+
22
+ return NextResponse.json({
23
+ user: {
24
+ id: row.id,
25
+ email: row.email,
26
+ displayName: row.display_name,
27
+ emailVerified: !!row.email_verified,
28
+ createdAt: row.created_at,
29
+ },
30
+ });
31
+ }
app/api/auth/register/route.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db';
5
+ import { sendVerificationEmail } from '@/lib/email';
6
+
7
+ const Schema = z.object({
8
+ email: z.string().email().max(255),
9
+ password: z.string().min(6).max(128),
10
+ displayName: z.string().max(50).optional(),
11
+ });
12
+
13
+ export async function POST(req: Request) {
14
+ try {
15
+ const body = await req.json();
16
+ const { email, password, displayName } = Schema.parse(body);
17
+
18
+ const db = getDb();
19
+
20
+ const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
21
+ if (existing) {
22
+ return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 });
23
+ }
24
+
25
+ const id = genId();
26
+ const hash = bcrypt.hashSync(password, 10);
27
+ const code = genVerificationCode();
28
+ const expires = codeExpiry();
29
+
30
+ db.prepare(
31
+ `INSERT INTO users (id, email, password, display_name, verification_code, verification_expires)
32
+ VALUES (?, ?, ?, ?, ?, ?)`,
33
+ ).run(id, email.toLowerCase(), hash, displayName || null, code, expires);
34
+
35
+ // Auto-login
36
+ const token = genToken();
37
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry());
38
+
39
+ // Send verification email (best-effort, don't block registration)
40
+ sendVerificationEmail(email, code).catch(() => {});
41
+
42
+ return NextResponse.json(
43
+ {
44
+ user: { id, email: email.toLowerCase(), displayName, emailVerified: false },
45
+ token,
46
+ message: 'Account created. Check your email for a verification code.',
47
+ },
48
+ { status: 201 },
49
+ );
50
+ } catch (error: any) {
51
+ if (error instanceof z.ZodError) {
52
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
53
+ }
54
+ console.error('[Auth Register]', error?.message);
55
+ return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
56
+ }
57
+ }
app/api/auth/resend-verification/route.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb, genVerificationCode, codeExpiry } from '@/lib/db';
3
+ import { authenticateRequest } from '@/lib/auth-middleware';
4
+ import { sendVerificationEmail } from '@/lib/email';
5
+
6
+ /**
7
+ * POST /api/auth/resend-verification — resend the 6-digit verification code.
8
+ */
9
+ export async function POST(req: Request) {
10
+ const user = authenticateRequest(req);
11
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
12
+
13
+ const db = getDb();
14
+ const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any;
15
+
16
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
17
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
18
+
19
+ const code = genVerificationCode();
20
+ db.prepare(
21
+ `UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now')
22
+ WHERE id = ?`,
23
+ ).run(code, codeExpiry(), user.id);
24
+
25
+ await sendVerificationEmail(row.email, code);
26
+
27
+ return NextResponse.json({ message: 'Verification code sent' });
28
+ }
app/api/auth/reset-password/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry } from '@/lib/db';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ code: z.string().length(6),
9
+ newPassword: z.string().min(6).max(128),
10
+ });
11
+
12
+ /**
13
+ * POST /api/auth/reset-password — reset password with the 6-digit code.
14
+ * On success, auto-logs the user in and returns a session token.
15
+ */
16
+ export async function POST(req: Request) {
17
+ try {
18
+ const body = await req.json();
19
+ const { email, code, newPassword } = Schema.parse(body);
20
+
21
+ const db = getDb();
22
+ const user = db
23
+ .prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?')
24
+ .get(email.toLowerCase()) as any;
25
+
26
+ if (
27
+ !user ||
28
+ user.reset_token !== code ||
29
+ !user.reset_expires ||
30
+ new Date(user.reset_expires) < new Date()
31
+ ) {
32
+ return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 });
33
+ }
34
+
35
+ const hash = bcrypt.hashSync(newPassword, 10);
36
+ db.prepare(
37
+ `UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now')
38
+ WHERE id = ?`,
39
+ ).run(hash, user.id);
40
+
41
+ // Invalidate all existing sessions for this user (security best practice).
42
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
43
+
44
+ // Auto-login with new session.
45
+ const token = genToken();
46
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
47
+ token,
48
+ user.id,
49
+ sessionExpiry(),
50
+ );
51
+
52
+ return NextResponse.json({
53
+ message: 'Password reset successfully',
54
+ token,
55
+ });
56
+ } catch (error: any) {
57
+ if (error instanceof z.ZodError) {
58
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
59
+ }
60
+ console.error('[Auth ResetPassword]', error?.message);
61
+ return NextResponse.json({ error: 'Reset failed' }, { status: 500 });
62
+ }
63
+ }
app/api/auth/verify-email/route.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { sendWelcomeEmail } from '@/lib/email';
6
+
7
+ const Schema = z.object({
8
+ code: z.string().length(6),
9
+ });
10
+
11
+ /**
12
+ * POST /api/auth/verify-email — verify email with 6-digit code.
13
+ * Requires auth (the user must be logged in to verify their own email).
14
+ */
15
+ export async function POST(req: Request) {
16
+ const user = authenticateRequest(req);
17
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
18
+
19
+ try {
20
+ const body = await req.json();
21
+ const { code } = Schema.parse(body);
22
+
23
+ const db = getDb();
24
+ const row = db
25
+ .prepare(
26
+ `SELECT verification_code, verification_expires, email, email_verified
27
+ FROM users WHERE id = ?`,
28
+ )
29
+ .get(user.id) as any;
30
+
31
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
32
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
33
+
34
+ if (
35
+ row.verification_code !== code ||
36
+ !row.verification_expires ||
37
+ new Date(row.verification_expires) < new Date()
38
+ ) {
39
+ return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 });
40
+ }
41
+
42
+ db.prepare(
43
+ `UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now')
44
+ WHERE id = ?`,
45
+ ).run(user.id);
46
+
47
+ // Send welcome email
48
+ sendWelcomeEmail(row.email).catch(() => {});
49
+
50
+ return NextResponse.json({ message: 'Email verified successfully', emailVerified: true });
51
+ } catch (error: any) {
52
+ if (error instanceof z.ZodError) {
53
+ return NextResponse.json({ error: 'Invalid code format' }, { status: 400 });
54
+ }
55
+ console.error('[Auth Verify]', error?.message);
56
+ return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
57
+ }
58
+ }
app/api/chat-history/route.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * GET /api/chat-history → list conversations (newest first, max 100)
8
+ * POST /api/chat-history → save a conversation
9
+ * DELETE /api/chat-history?id=<id> → delete one conversation
10
+ */
11
+
12
+ export async function GET(req: Request) {
13
+ const user = authenticateRequest(req);
14
+ if (!user) {
15
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
16
+ }
17
+
18
+ const db = getDb();
19
+ const rows = db
20
+ .prepare(
21
+ 'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100',
22
+ )
23
+ .all(user.id) as any[];
24
+
25
+ return NextResponse.json({
26
+ conversations: rows.map((r) => ({
27
+ id: r.id,
28
+ preview: r.preview,
29
+ topic: r.topic,
30
+ createdAt: r.created_at,
31
+ })),
32
+ });
33
+ }
34
+
35
+ const SaveSchema = z.object({
36
+ preview: z.string().max(200),
37
+ messages: z.array(
38
+ z.object({
39
+ role: z.enum(['user', 'assistant', 'system']),
40
+ content: z.string(),
41
+ }),
42
+ ),
43
+ topic: z.string().max(50).optional(),
44
+ });
45
+
46
+ export async function POST(req: Request) {
47
+ const user = authenticateRequest(req);
48
+ if (!user) {
49
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
50
+ }
51
+
52
+ try {
53
+ const body = await req.json();
54
+ const { preview, messages, topic } = SaveSchema.parse(body);
55
+
56
+ const db = getDb();
57
+ const id = genId();
58
+
59
+ db.prepare(
60
+ 'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)',
61
+ ).run(id, user.id, preview, JSON.stringify(messages), topic || null);
62
+
63
+ return NextResponse.json({ id }, { status: 201 });
64
+ } catch (error: any) {
65
+ if (error instanceof z.ZodError) {
66
+ return NextResponse.json(
67
+ { error: 'Invalid input', details: error.errors },
68
+ { status: 400 },
69
+ );
70
+ }
71
+ console.error('[Chat History POST]', error?.message);
72
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
73
+ }
74
+ }
75
+
76
+ export async function DELETE(req: Request) {
77
+ const user = authenticateRequest(req);
78
+ if (!user) {
79
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
80
+ }
81
+
82
+ const url = new URL(req.url);
83
+ const id = url.searchParams.get('id');
84
+ if (!id) {
85
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
86
+ }
87
+
88
+ const db = getDb();
89
+ db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run(
90
+ id,
91
+ user.id,
92
+ );
93
+
94
+ return NextResponse.json({ success: true });
95
+ }
app/api/chat/route.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { streamWithFallback, type ChatMessage } from '@/lib/providers';
4
+ import { triageMessage } from '@/lib/safety/triage';
5
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
6
+ import { buildRAGContext } from '@/lib/rag/medical-kb';
7
+ import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge';
8
+
9
+ const RequestSchema = z.object({
10
+ messages: z.array(
11
+ z.object({
12
+ role: z.enum(['system', 'user', 'assistant']),
13
+ content: z.string(),
14
+ })
15
+ ),
16
+ model: z.string().optional().default('qwen2.5:1.5b'),
17
+ language: z.string().optional().default('en'),
18
+ countryCode: z.string().optional().default('US'),
19
+ });
20
+
21
+ export async function POST(request: NextRequest) {
22
+ try {
23
+ const body = await request.json();
24
+ const { messages, model, language, countryCode } = RequestSchema.parse(body);
25
+
26
+ // Step 1: Emergency triage on the latest user message
27
+ const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
28
+ if (lastUserMessage) {
29
+ const triage = triageMessage(lastUserMessage.content);
30
+
31
+ if (triage.isEmergency) {
32
+ const emergencyInfo = getEmergencyInfo(countryCode);
33
+ const emergencyResponse = [
34
+ `**EMERGENCY DETECTED**\n\n`,
35
+ `${triage.guidance}\n\n`,
36
+ `**Call emergency services NOW:**\n`,
37
+ `- Emergency: **${emergencyInfo.emergency}** (${emergencyInfo.country})\n`,
38
+ `- Ambulance: **${emergencyInfo.ambulance}**\n`,
39
+ emergencyInfo.crisisHotline
40
+ ? `- Crisis Hotline: **${emergencyInfo.crisisHotline}**\n`
41
+ : '',
42
+ `\nDo not delay. Every minute matters.`,
43
+ ].join('');
44
+
45
+ const encoder = new TextEncoder();
46
+ const stream = new ReadableStream({
47
+ start(controller) {
48
+ const data = JSON.stringify({
49
+ choices: [{ delta: { content: emergencyResponse } }],
50
+ provider: 'triage',
51
+ model: 'emergency-detection',
52
+ isEmergency: true,
53
+ });
54
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
55
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
56
+ controller.close();
57
+ },
58
+ });
59
+
60
+ return new Response(stream, {
61
+ headers: {
62
+ 'Content-Type': 'text/event-stream',
63
+ 'Cache-Control': 'no-cache',
64
+ Connection: 'keep-alive',
65
+ },
66
+ });
67
+ }
68
+ }
69
+
70
+ // Step 2: Build RAG context from the medical knowledge base.
71
+ const ragContext = lastUserMessage
72
+ ? buildRAGContext(lastUserMessage.content)
73
+ : '';
74
+
75
+ // Step 3: Build a structured, locale-aware system prompt that grounds
76
+ // the model in WHO/CDC/NHS guidance and pins the response language,
77
+ // country, emergency number, and measurement system. This replaces
78
+ // the old inline "respond in X language" instruction.
79
+ const emergencyInfo = getEmergencyInfo(countryCode);
80
+ const systemPrompt = buildMedicalSystemPrompt({
81
+ country: countryCode,
82
+ language,
83
+ emergencyNumber: emergencyInfo.emergency,
84
+ });
85
+
86
+ // Step 4: Assemble the final message list: system prompt first, then
87
+ // the conversation history, with the last user turn augmented by the
88
+ // retrieved RAG context (kept inline so the model treats it as recent
89
+ // reference material rather than a background instruction).
90
+ const priorMessages = messages.slice(0, -1);
91
+ const finalUserContent = [
92
+ lastUserMessage?.content || '',
93
+ ragContext
94
+ ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}`
95
+ : '',
96
+ ].join('');
97
+
98
+ const augmentedMessages: ChatMessage[] = [
99
+ { role: 'system' as const, content: systemPrompt },
100
+ ...priorMessages,
101
+ { role: 'user' as const, content: finalUserContent },
102
+ ];
103
+
104
+ // Step 4: Stream response from OllaBridge-Cloud (with fallback chain)
105
+ const stream = await streamWithFallback(augmentedMessages, model);
106
+
107
+ return new Response(stream, {
108
+ headers: {
109
+ 'Content-Type': 'text/event-stream',
110
+ 'Cache-Control': 'no-cache',
111
+ Connection: 'keep-alive',
112
+ },
113
+ });
114
+ } catch (error) {
115
+ console.error('[Chat API Error]:', error);
116
+
117
+ if (error instanceof z.ZodError) {
118
+ return new Response(
119
+ JSON.stringify({ error: 'Invalid request', details: error.errors }),
120
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
121
+ );
122
+ }
123
+
124
+ return new Response(
125
+ JSON.stringify({ error: 'Internal server error' }),
126
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
127
+ );
128
+ }
129
+ }
app/api/geo/route.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
3
+
4
+ export const runtime = 'nodejs';
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * IP-based country + language + emergency number detection.
9
+ *
10
+ * Privacy posture:
11
+ * - Platform geo headers are read first (zero external calls, zero PII).
12
+ * - If nothing is present we fall back to ipapi.co (free, no key), but
13
+ * ONLY for public IPs — RFC1918, loopback, and link-local are never
14
+ * sent outbound.
15
+ * - The client IP is never logged or returned.
16
+ */
17
+
18
+ const GEO_HEADERS = [
19
+ 'x-vercel-ip-country',
20
+ 'cf-ipcountry',
21
+ 'x-nf-country',
22
+ 'cloudfront-viewer-country',
23
+ 'x-appengine-country',
24
+ 'fly-client-ip-country',
25
+ 'x-forwarded-country',
26
+ ] as const;
27
+
28
+ // Country → best-effort primary language out of the ones MedOS ships.
29
+ // Kept local to this file so we don't bloat lib/i18n for a single use.
30
+ const COUNTRY_TO_LANGUAGE: Record<string, string> = {
31
+ US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en',
32
+ NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en',
33
+ PK: 'en', BD: 'en', LK: 'en', PH: 'en',
34
+ ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es',
35
+ EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es',
36
+ SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es',
37
+ BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt',
38
+ FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr',
39
+ CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar',
40
+ DE: 'de', AT: 'de', CH: 'de', LI: 'de',
41
+ IT: 'it', SM: 'it', VA: 'it',
42
+ NL: 'nl', SR: 'nl',
43
+ PL: 'pl',
44
+ RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru',
45
+ TR: 'tr',
46
+ SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar',
47
+ YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar',
48
+ PS: 'ar',
49
+ CN: 'zh', TW: 'zh', HK: 'zh',
50
+ JP: 'ja',
51
+ KR: 'ko',
52
+ TH: 'th',
53
+ VN: 'vi',
54
+ TZ: 'sw',
55
+ };
56
+
57
+ function pickHeaderCountry(req: Request): string | null {
58
+ for (const h of GEO_HEADERS) {
59
+ const v = req.headers.get(h);
60
+ if (v && v.length >= 2 && v.toUpperCase() !== 'XX') {
61
+ return v.toUpperCase().slice(0, 2);
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function extractClientIp(req: Request): string | null {
68
+ const xff = req.headers.get('x-forwarded-for');
69
+ if (xff) {
70
+ const first = xff.split(',')[0]?.trim();
71
+ if (first) return first;
72
+ }
73
+ return req.headers.get('x-real-ip');
74
+ }
75
+
76
+ function isPrivateIp(ip: string): boolean {
77
+ if (!ip) return true;
78
+ if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true;
79
+ const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
80
+ if (!m) return false;
81
+ const a = parseInt(m[1], 10);
82
+ const b = parseInt(m[2], 10);
83
+ if (a === 10) return true;
84
+ if (a === 127) return true;
85
+ if (a === 169 && b === 254) return true;
86
+ if (a === 172 && b >= 16 && b <= 31) return true;
87
+ if (a === 192 && b === 168) return true;
88
+ return false;
89
+ }
90
+
91
+ async function lookupIpapi(ip: string): Promise<string | null> {
92
+ try {
93
+ const controller = new AbortController();
94
+ const timeout = setTimeout(() => controller.abort(), 1500);
95
+ const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, {
96
+ signal: controller.signal,
97
+ headers: { 'User-Agent': 'MedOS-Geo/1.0' },
98
+ });
99
+ clearTimeout(timeout);
100
+ if (!res.ok) return null;
101
+ const text = (await res.text()).trim().toUpperCase();
102
+ if (/^[A-Z]{2}$/.test(text)) return text;
103
+ return null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function GET(req: Request): Promise<Response> {
110
+ let country = pickHeaderCountry(req);
111
+ let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default';
112
+
113
+ if (!country) {
114
+ const ip = extractClientIp(req);
115
+ if (ip && !isPrivateIp(ip)) {
116
+ const looked = await lookupIpapi(ip);
117
+ if (looked) {
118
+ country = looked;
119
+ source = 'ipapi';
120
+ }
121
+ }
122
+ }
123
+
124
+ const finalCountry = country || 'US';
125
+ const info = getEmergencyInfo(finalCountry);
126
+ const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en';
127
+
128
+ return NextResponse.json(
129
+ {
130
+ country: finalCountry,
131
+ language,
132
+ emergencyNumber: info.emergency,
133
+ source,
134
+ },
135
+ {
136
+ headers: {
137
+ 'Cache-Control': 'private, max-age=3600',
138
+ 'X-Robots-Tag': 'noindex',
139
+ },
140
+ },
141
+ );
142
+ }
app/api/health-data/route.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * GET /api/health-data → fetch all health data for the user
8
+ * GET /api/health-data?type=vital → filter by type
9
+ * POST /api/health-data/sync → bulk sync from client localStorage
10
+ */
11
+ export async function GET(req: Request) {
12
+ const user = authenticateRequest(req);
13
+ if (!user) {
14
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
15
+ }
16
+
17
+ const db = getDb();
18
+ const url = new URL(req.url);
19
+ const type = url.searchParams.get('type');
20
+
21
+ const rows = type
22
+ ? db
23
+ .prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC')
24
+ .all(user.id, type)
25
+ : db
26
+ .prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC')
27
+ .all(user.id);
28
+
29
+ // Parse the JSON `data` field back into objects.
30
+ const items = (rows as any[]).map((r) => ({
31
+ id: r.id,
32
+ type: r.type,
33
+ data: JSON.parse(r.data),
34
+ createdAt: r.created_at,
35
+ updatedAt: r.updated_at,
36
+ }));
37
+
38
+ return NextResponse.json({ items });
39
+ }
40
+
41
+ /**
42
+ * POST /api/health-data — upsert a single health-data record.
43
+ */
44
+ const UpsertSchema = z.object({
45
+ id: z.string().optional(),
46
+ type: z.enum([
47
+ 'medication',
48
+ 'medication_log',
49
+ 'appointment',
50
+ 'vital',
51
+ 'record',
52
+ 'conversation',
53
+ ]),
54
+ data: z.record(z.any()),
55
+ });
56
+
57
+ export async function POST(req: Request) {
58
+ const user = authenticateRequest(req);
59
+ if (!user) {
60
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
61
+ }
62
+
63
+ try {
64
+ const body = await req.json();
65
+ const { id, type, data } = UpsertSchema.parse(body);
66
+
67
+ const db = getDb();
68
+ const itemId = id || genId();
69
+ const json = JSON.stringify(data);
70
+
71
+ // Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly.
72
+ db.prepare(
73
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
74
+ VALUES (?, ?, ?, ?, datetime('now'))
75
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
76
+ ).run(itemId, user.id, type, json);
77
+
78
+ return NextResponse.json({ id: itemId, type }, { status: 201 });
79
+ } catch (error: any) {
80
+ if (error instanceof z.ZodError) {
81
+ return NextResponse.json(
82
+ { error: 'Invalid input', details: error.errors },
83
+ { status: 400 },
84
+ );
85
+ }
86
+ console.error('[Health Data POST]', error?.message);
87
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * DELETE /api/health-data?id=<id> — delete one record.
93
+ */
94
+ export async function DELETE(req: Request) {
95
+ const user = authenticateRequest(req);
96
+ if (!user) {
97
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
98
+ }
99
+
100
+ const url = new URL(req.url);
101
+ const id = url.searchParams.get('id');
102
+ if (!id) {
103
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
104
+ }
105
+
106
+ const db = getDb();
107
+ db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run(
108
+ id,
109
+ user.id,
110
+ );
111
+
112
+ return NextResponse.json({ success: true });
113
+ }
app/api/health-data/sync/route.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * POST /api/health-data/sync — bulk sync from client localStorage.
8
+ *
9
+ * The client sends its entire localStorage health dataset (medications,
10
+ * appointments, vitals, records, medication_logs, conversations). The
11
+ * server upserts each item. This runs on:
12
+ * - First login (migrates existing guest data to the account)
13
+ * - Periodic background sync while logged in
14
+ *
15
+ * Idempotent: calling it twice with the same data is safe.
16
+ */
17
+
18
+ const ItemSchema = z.object({
19
+ id: z.string(),
20
+ type: z.enum([
21
+ 'medication',
22
+ 'medication_log',
23
+ 'appointment',
24
+ 'vital',
25
+ 'record',
26
+ 'conversation',
27
+ ]),
28
+ data: z.record(z.any()),
29
+ });
30
+
31
+ const SyncSchema = z.object({
32
+ items: z.array(ItemSchema).max(5000),
33
+ });
34
+
35
+ export async function POST(req: Request) {
36
+ const user = authenticateRequest(req);
37
+ if (!user) {
38
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
39
+ }
40
+
41
+ try {
42
+ const body = await req.json();
43
+ const { items } = SyncSchema.parse(body);
44
+
45
+ const db = getDb();
46
+
47
+ const upsert = db.prepare(
48
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
49
+ VALUES (?, ?, ?, ?, datetime('now'))
50
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
51
+ );
52
+
53
+ // Run as a single transaction for speed (1000+ items in <50ms).
54
+ const tx = db.transaction(() => {
55
+ for (const item of items) {
56
+ upsert.run(item.id, user.id, item.type, JSON.stringify(item.data));
57
+ }
58
+ });
59
+ tx();
60
+
61
+ return NextResponse.json({
62
+ synced: items.length,
63
+ message: `${items.length} items synced`,
64
+ });
65
+ } catch (error: any) {
66
+ if (error instanceof z.ZodError) {
67
+ return NextResponse.json(
68
+ { error: 'Invalid input', details: error.errors },
69
+ { status: 400 },
70
+ );
71
+ }
72
+ console.error('[Health Data Sync]', error?.message);
73
+ return NextResponse.json({ error: 'Sync failed' }, { status: 500 });
74
+ }
75
+ }
app/api/health/route.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ status: 'healthy',
6
+ service: 'medos-global',
7
+ timestamp: new Date().toISOString(),
8
+ version: '1.0.0',
9
+ });
10
+ }
app/api/models/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { fetchAvailableModels } from '@/lib/providers/ollabridge-models';
3
+
4
+ export async function GET() {
5
+ try {
6
+ const models = await fetchAvailableModels();
7
+ return NextResponse.json({ models });
8
+ } catch {
9
+ return NextResponse.json(
10
+ { models: [], error: 'Failed to fetch models' },
11
+ { status: 200 } // Return 200 with empty array — non-critical endpoint
12
+ );
13
+ }
14
+ }
app/api/og/route.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ImageResponse } from 'next/og';
2
+
3
+ export const runtime = 'edge';
4
+
5
+ /**
6
+ * Dynamic Open Graph image endpoint.
7
+ *
8
+ * Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage
9
+ * renders a branded 1200x630 card. The query becomes the card title so
10
+ * a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews
11
+ * as a premium, unique image instead of the default favicon blob.
12
+ *
13
+ * Usage from the client: `/api/og?q=<question>&lang=<code>`
14
+ * The endpoint also handles missing parameters gracefully (returns a
15
+ * default brand card).
16
+ */
17
+ export async function GET(req: Request): Promise<Response> {
18
+ try {
19
+ const { searchParams } = new URL(req.url);
20
+ const rawQuery = (searchParams.get('q') || '').trim();
21
+ const lang = (searchParams.get('lang') || 'en').slice(0, 5);
22
+
23
+ // Hard-limit title length so long queries don't overflow.
24
+ const title =
25
+ rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery;
26
+
27
+ const subtitle = title
28
+ ? 'Ask MedOS — free, private, in your language'
29
+ : 'Free AI medical assistant — 20 languages, no sign-up';
30
+
31
+ const headline = title || 'Tell me what\'s bothering you.';
32
+
33
+ return new ImageResponse(
34
+ (
35
+ <div
36
+ style={{
37
+ width: '100%',
38
+ height: '100%',
39
+ display: 'flex',
40
+ flexDirection: 'column',
41
+ padding: '72px',
42
+ background:
43
+ 'radial-gradient(1200px 800px at 10% -10%, rgba(59,130,246,0.35), transparent 60%),' +
44
+ 'radial-gradient(1000px 600px at 110% 10%, rgba(20,184,166,0.30), transparent 60%),' +
45
+ 'linear-gradient(180deg, #0B1220 0%, #0E1627 100%)',
46
+ color: '#F8FAFC',
47
+ fontFamily: 'sans-serif',
48
+ position: 'relative',
49
+ }}
50
+ >
51
+ {/* Top bar: brand mark + language chip */}
52
+ <div
53
+ style={{
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'space-between',
57
+ marginBottom: '48px',
58
+ }}
59
+ >
60
+ <div style={{ display: 'flex', alignItems: 'center', gap: '18px' }}>
61
+ <div
62
+ style={{
63
+ width: '72px',
64
+ height: '72px',
65
+ borderRadius: '22px',
66
+ background: 'linear-gradient(135deg, #3B82F6 0%, #14B8A6 100%)',
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ fontSize: '44px',
71
+ boxShadow: '0 20px 60px -10px rgba(59,130,246,0.65)',
72
+ }}
73
+ >
74
+
75
+ </div>
76
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
77
+ <div
78
+ style={{
79
+ fontSize: '40px',
80
+ fontWeight: 800,
81
+ letterSpacing: '-0.02em',
82
+ lineHeight: 1,
83
+ }}
84
+ >
85
+ MedOS
86
+ </div>
87
+ <div
88
+ style={{
89
+ fontSize: '16px',
90
+ color: '#14B8A6',
91
+ fontWeight: 700,
92
+ textTransform: 'uppercase',
93
+ letterSpacing: '0.18em',
94
+ marginTop: '6px',
95
+ }}
96
+ >
97
+ Worldwide medical AI
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div
103
+ style={{
104
+ display: 'flex',
105
+ alignItems: 'center',
106
+ gap: '10px',
107
+ padding: '10px 18px',
108
+ borderRadius: '9999px',
109
+ background: 'rgba(255,255,255,0.08)',
110
+ border: '1px solid rgba(255,255,255,0.18)',
111
+ fontSize: '18px',
112
+ color: '#CBD5E1',
113
+ fontWeight: 600,
114
+ }}
115
+ >
116
+ <span
117
+ style={{
118
+ width: '8px',
119
+ height: '8px',
120
+ borderRadius: '9999px',
121
+ background: '#22C55E',
122
+ }}
123
+ />
124
+ {lang.toUpperCase()} · FREE · NO SIGN-UP
125
+ </div>
126
+ </div>
127
+
128
+ {/* Main content */}
129
+ <div
130
+ style={{
131
+ display: 'flex',
132
+ flexDirection: 'column',
133
+ flex: 1,
134
+ justifyContent: 'center',
135
+ }}
136
+ >
137
+ {title && (
138
+ <div
139
+ style={{
140
+ fontSize: '22px',
141
+ fontWeight: 700,
142
+ color: '#14B8A6',
143
+ textTransform: 'uppercase',
144
+ letterSpacing: '0.18em',
145
+ marginBottom: '22px',
146
+ }}
147
+ >
148
+ Ask MedOS
149
+ </div>
150
+ )}
151
+ <div
152
+ style={{
153
+ fontSize: title ? '68px' : '84px',
154
+ fontWeight: 800,
155
+ lineHeight: 1.1,
156
+ letterSpacing: '-0.025em',
157
+ color: '#F8FAFC',
158
+ maxWidth: '1000px',
159
+ }}
160
+ >
161
+ {title ? `"${headline}"` : headline}
162
+ </div>
163
+ <div
164
+ style={{
165
+ fontSize: '26px',
166
+ color: '#94A3B8',
167
+ marginTop: '28px',
168
+ fontWeight: 500,
169
+ }}
170
+ >
171
+ {subtitle}
172
+ </div>
173
+ </div>
174
+
175
+ {/* Footer: trust strip */}
176
+ <div
177
+ style={{
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ gap: '28px',
181
+ fontSize: '18px',
182
+ color: '#94A3B8',
183
+ fontWeight: 600,
184
+ borderTop: '1px solid rgba(255,255,255,0.1)',
185
+ paddingTop: '28px',
186
+ }}
187
+ >
188
+ <span style={{ color: '#14B8A6', fontWeight: 700 }}>
189
+ ✓ Aligned with WHO · CDC · NHS
190
+ </span>
191
+ <span>·</span>
192
+ <span>Private &amp; anonymous</span>
193
+ <span>·</span>
194
+ <span>24/7</span>
195
+ </div>
196
+ </div>
197
+ ),
198
+ {
199
+ width: 1200,
200
+ height: 630,
201
+ },
202
+ );
203
+ } catch {
204
+ // Never 500 an OG endpoint — social crawlers will blacklist the domain.
205
+ return new Response('OG image generation failed', { status: 500 });
206
+ }
207
+ }
app/api/rag/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { searchMedicalKB } from '@/lib/rag/medical-kb';
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { query, topN = 3 } = await request.json();
7
+
8
+ if (!query || typeof query !== 'string') {
9
+ return NextResponse.json(
10
+ { error: 'Query is required' },
11
+ { status: 400 }
12
+ );
13
+ }
14
+
15
+ const results = searchMedicalKB(query, topN);
16
+
17
+ return NextResponse.json({
18
+ results: results.map((r) => ({
19
+ topic: r.topic,
20
+ context: r.context,
21
+ })),
22
+ count: results.length,
23
+ });
24
+ } catch {
25
+ return NextResponse.json(
26
+ { error: 'Internal server error' },
27
+ { status: 500 }
28
+ );
29
+ }
30
+ }
app/api/sessions/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Server-side session counter.
7
+ * Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart).
8
+ * On HF Spaces with persistent storage, use /data/ instead of /tmp/.
9
+ *
10
+ * GET /api/sessions → returns { count: number }
11
+ * POST /api/sessions → increments and returns { count: number }
12
+ */
13
+
14
+ const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data';
15
+ const COUNTER_FILE = join(DATA_DIR, 'sessions.json');
16
+ const BASE_COUNT = 423000; // Historical base from before server-side tracking
17
+
18
+ interface CounterData {
19
+ count: number;
20
+ lastUpdated: string;
21
+ }
22
+
23
+ function ensureDir(): void {
24
+ if (!existsSync(DATA_DIR)) {
25
+ mkdirSync(DATA_DIR, { recursive: true });
26
+ }
27
+ }
28
+
29
+ function readCounter(): number {
30
+ ensureDir();
31
+ try {
32
+ if (existsSync(COUNTER_FILE)) {
33
+ const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8'));
34
+ return data.count;
35
+ }
36
+ } catch {
37
+ // corrupted file, reset
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ function incrementCounter(): number {
43
+ ensureDir();
44
+ const current = readCounter();
45
+ const next = current + 1;
46
+ const data: CounterData = {
47
+ count: next,
48
+ lastUpdated: new Date().toISOString(),
49
+ };
50
+ writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8');
51
+ return next;
52
+ }
53
+
54
+ export async function GET() {
55
+ const sessionCount = readCounter();
56
+ return NextResponse.json({
57
+ count: BASE_COUNT + sessionCount,
58
+ sessions: sessionCount,
59
+ base: BASE_COUNT,
60
+ });
61
+ }
62
+
63
+ export async function POST() {
64
+ const sessionCount = incrementCounter();
65
+ return NextResponse.json({
66
+ count: BASE_COUNT + sessionCount,
67
+ sessions: sessionCount,
68
+ base: BASE_COUNT,
69
+ });
70
+ }
app/api/triage/route.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { triageMessage } from '@/lib/safety/triage';
3
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
4
+
5
+ export async function POST(request: NextRequest) {
6
+ try {
7
+ const { message, countryCode = 'US' } = await request.json();
8
+
9
+ if (!message || typeof message !== 'string') {
10
+ return NextResponse.json(
11
+ { error: 'Message is required' },
12
+ { status: 400 }
13
+ );
14
+ }
15
+
16
+ const triage = triageMessage(message);
17
+ const emergencyInfo = getEmergencyInfo(countryCode);
18
+
19
+ return NextResponse.json({
20
+ ...triage,
21
+ emergencyInfo: triage.isEmergency ? emergencyInfo : null,
22
+ });
23
+ } catch {
24
+ return NextResponse.json(
25
+ { error: 'Internal server error' },
26
+ { status: 500 }
27
+ );
28
+ }
29
+ }
app/globals.css ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ===== CSS Custom Properties ===== */
6
+ :root {
7
+ --vh: 1vh;
8
+ --color-bg-primary: #f7f9fb;
9
+ --color-bg-secondary: #f1f5f9;
10
+ --color-bg-tertiary: #e2e8f0;
11
+ --color-text-primary: #0f172a;
12
+ --color-text-secondary: #475569;
13
+ --color-accent: #3b82f6;
14
+ --color-accent-secondary: #10b981;
15
+ --color-danger: #ef4444;
16
+ --color-warning: #f59e0b;
17
+ --safe-area-top: env(safe-area-inset-top, 0px);
18
+ --safe-area-bottom: env(safe-area-inset-bottom, 0px);
19
+ --safe-area-left: env(safe-area-inset-left, 0px);
20
+ --safe-area-right: env(safe-area-inset-right, 0px);
21
+ color-scheme: light;
22
+ }
23
+
24
+ .dark {
25
+ --color-bg-primary: #0f172a;
26
+ --color-bg-secondary: #1e293b;
27
+ --color-bg-tertiary: #334155;
28
+ --color-text-primary: #f8fafc;
29
+ --color-text-secondary: #94a3b8;
30
+ color-scheme: dark;
31
+ }
32
+
33
+ /* ============================================================
34
+ * Light-mode compatibility layer.
35
+ *
36
+ * Most components were originally built dark-only with hardcoded
37
+ * slate-700/800/900 classes. Rather than adding dark: prefixes to
38
+ * every single class in every file (15+ views), we remap the most
39
+ * common dark-palette utilities to their light equivalents at the
40
+ * CSS level. In .dark mode these rules don't apply, so the
41
+ * original dark colors remain.
42
+ *
43
+ * This is intentionally !important so it overrides Tailwind's
44
+ * generated utilities (which have equal specificity).
45
+ * ============================================================ */
46
+
47
+ /* --- Backgrounds --- */
48
+ :root:not(.dark) .bg-slate-900 { background-color: #ffffff !important; }
49
+ :root:not(.dark) .bg-slate-900\/95 { background-color: rgba(255,255,255,0.95) !important; }
50
+ :root:not(.dark) .bg-slate-900\/85 { background-color: rgba(255,255,255,0.90) !important; }
51
+ :root:not(.dark) .bg-slate-800 { background-color: #f8fafc !important; }
52
+ :root:not(.dark) .bg-slate-800\/50 { background-color: rgba(241,245,249,0.7) !important; }
53
+ :root:not(.dark) .bg-slate-800\/30 { background-color: rgba(241,245,249,0.5) !important; }
54
+ :root:not(.dark) .bg-slate-800\/80 { background-color: rgba(241,245,249,0.85) !important; }
55
+ :root:not(.dark) .bg-slate-700 { background-color: #e2e8f0 !important; }
56
+ :root:not(.dark) .bg-slate-700\/60 { background-color: rgba(226,232,240,0.7) !important; }
57
+
58
+ /* Tinted surfaces */
59
+ :root:not(.dark) .bg-red-950\/50 { background-color: rgba(254,226,226,0.6) !important; }
60
+
61
+ /* --- Text --- */
62
+ :root:not(.dark) .text-slate-50 { color: #0f172a !important; }
63
+ :root:not(.dark) .text-slate-100 { color: #1e293b !important; }
64
+ :root:not(.dark) .text-slate-200 { color: #334155 !important; }
65
+ :root:not(.dark) .text-slate-300 { color: #475569 !important; }
66
+ :root:not(.dark) .text-slate-400 { color: #64748b !important; }
67
+ :root:not(.dark) .text-red-300 { color: #dc2626 !important; }
68
+ :root:not(.dark) .text-red-400 { color: #ef4444 !important; }
69
+ :root:not(.dark) .text-blue-200 { color: #3b82f6 !important; }
70
+ :root:not(.dark) .text-teal-300 { color: #0d9488 !important; }
71
+ :root:not(.dark) .text-teal-400 { color: #14b8a6 !important; }
72
+
73
+ /* --- Borders --- */
74
+ :root:not(.dark) .border-slate-700\/50 { border-color: rgba(226,232,240,0.8) !important; }
75
+ :root:not(.dark) .border-slate-700\/30 { border-color: rgba(226,232,240,0.6) !important; }
76
+ :root:not(.dark) .border-slate-700\/60 { border-color: rgba(226,232,240,0.7) !important; }
77
+ :root:not(.dark) .border-slate-700 { border-color: #e2e8f0 !important; }
78
+ :root:not(.dark) .border-red-700\/50 { border-color: rgba(252,165,165,0.6) !important; }
79
+ :root:not(.dark) .border-red-700\/30 { border-color: rgba(252,165,165,0.4) !important; }
80
+
81
+ /* --- Placeholder text --- */
82
+ :root:not(.dark) .placeholder-slate-500::placeholder { color: #94a3b8 !important; }
83
+
84
+ /* --- Hover states --- */
85
+ :root:not(.dark) .hover\:bg-slate-800:hover { background-color: #f1f5f9 !important; }
86
+ :root:not(.dark) .hover\:bg-slate-700:hover { background-color: #e2e8f0 !important; }
87
+ :root:not(.dark) .hover\:bg-slate-600:hover { background-color: #cbd5e1 !important; }
88
+ :root:not(.dark) .hover\:text-slate-200:hover { color: #1e293b !important; }
89
+ :root:not(.dark) .hover\:text-slate-300:hover { color: #334155 !important; }
90
+ :root:not(.dark) .hover\:text-teal-200:hover { color: #0d9488 !important; }
91
+
92
+ /* --- Focus rings stay the same (blue) --- */
93
+
94
+ /* --- Right panel, sidebar, bottom nav overlays --- */
95
+ :root:not(.dark) .bg-slate-800\/95 { background-color: rgba(255,255,255,0.95) !important; }
96
+
97
+ /* ===== Base Styles ===== */
98
+ * {
99
+ box-sizing: border-box;
100
+ margin: 0;
101
+ padding: 0;
102
+ }
103
+
104
+ html {
105
+ -webkit-text-size-adjust: 100%;
106
+ -webkit-tap-highlight-color: transparent;
107
+ }
108
+
109
+ body {
110
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
111
+ 'Helvetica Neue', Arial, sans-serif;
112
+ background-color: var(--color-bg-primary);
113
+ color: var(--color-text-primary);
114
+ overflow: hidden;
115
+ overscroll-behavior: none;
116
+ transition: background-color 0.2s, color 0.2s;
117
+ }
118
+
119
+ /* ===== Mobile Viewport Height Fix (iOS Safari) ===== */
120
+ .h-screen-safe {
121
+ height: calc(var(--vh, 1vh) * 100);
122
+ }
123
+
124
+ /* ===== Safe Area Padding ===== */
125
+ .pt-safe {
126
+ padding-top: var(--safe-area-top);
127
+ }
128
+ .pb-safe {
129
+ padding-bottom: var(--safe-area-bottom);
130
+ }
131
+ .pl-safe {
132
+ padding-left: var(--safe-area-left);
133
+ }
134
+ .pr-safe {
135
+ padding-right: var(--safe-area-right);
136
+ }
137
+
138
+ /* ===== Prevent Zoom on Input Focus (iOS) ===== */
139
+ input,
140
+ textarea,
141
+ select {
142
+ font-size: 16px !important;
143
+ }
144
+
145
+ /* ===== Touch Targets (Apple HIG: 44x44px minimum) ===== */
146
+ .touch-target {
147
+ min-height: 44px;
148
+ min-width: 44px;
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ }
153
+
154
+ /* ===== Smooth Scrolling ===== */
155
+ .scroll-smooth {
156
+ -webkit-overflow-scrolling: touch;
157
+ scroll-behavior: smooth;
158
+ }
159
+
160
+ /* ===== Custom Scrollbar ===== */
161
+ ::-webkit-scrollbar {
162
+ width: 6px;
163
+ height: 6px;
164
+ }
165
+
166
+ ::-webkit-scrollbar-track {
167
+ background: transparent;
168
+ }
169
+
170
+ ::-webkit-scrollbar-thumb {
171
+ background: #cbd5e1;
172
+ border-radius: 3px;
173
+ }
174
+
175
+ ::-webkit-scrollbar-thumb:hover {
176
+ background: #94a3b8;
177
+ }
178
+
179
+ .dark ::-webkit-scrollbar-thumb {
180
+ background: #475569;
181
+ }
182
+
183
+ .dark ::-webkit-scrollbar-thumb:hover {
184
+ background: #64748b;
185
+ }
186
+
187
+ /* ===== Selection Style ===== */
188
+ ::selection {
189
+ background-color: rgba(59, 130, 246, 0.3);
190
+ color: #f8fafc;
191
+ }
192
+
193
+ /* ===== RTL Support ===== */
194
+ [dir='rtl'] .sidebar-nav {
195
+ right: 0;
196
+ left: auto;
197
+ }
198
+
199
+ [dir='rtl'] .message-user {
200
+ margin-left: 0;
201
+ margin-right: auto;
202
+ }
203
+
204
+ [dir='rtl'] .message-bot {
205
+ margin-right: 0;
206
+ margin-left: auto;
207
+ }
208
+
209
+ [dir='rtl'] .chat-input-area {
210
+ flex-direction: row-reverse;
211
+ }
212
+
213
+ /* ===== Sidebar Menu Popup ===== */
214
+ .desktop-sidebar {
215
+ overflow: visible !important;
216
+ }
217
+
218
+ /* ===== Animation Utilities ===== */
219
+ .animate-delay-100 {
220
+ animation-delay: 100ms;
221
+ }
222
+
223
+ .animate-delay-200 {
224
+ animation-delay: 200ms;
225
+ }
226
+
227
+ .animate-delay-300 {
228
+ animation-delay: 300ms;
229
+ }
230
+
231
+ /* ===== Typing Indicator ===== */
232
+ .typing-dot {
233
+ width: 8px;
234
+ height: 8px;
235
+ border-radius: 50%;
236
+ background-color: #64748b;
237
+ animation: pulseDot 1.4s infinite ease-in-out both;
238
+ }
239
+
240
+ .typing-dot:nth-child(1) {
241
+ animation-delay: -0.32s;
242
+ }
243
+
244
+ .typing-dot:nth-child(2) {
245
+ animation-delay: -0.16s;
246
+ }
247
+
248
+ /* ===== Message Markdown Rendering ===== */
249
+ .message-content p {
250
+ margin-bottom: 0.5rem;
251
+ }
252
+
253
+ .message-content p:last-child {
254
+ margin-bottom: 0;
255
+ }
256
+
257
+ .message-content ul,
258
+ .message-content ol {
259
+ padding-left: 1.5rem;
260
+ margin-bottom: 0.5rem;
261
+ }
262
+
263
+ .message-content li {
264
+ margin-bottom: 0.25rem;
265
+ }
266
+
267
+ .message-content code {
268
+ background-color: rgba(71, 85, 105, 0.5);
269
+ padding: 0.125rem 0.375rem;
270
+ border-radius: 0.25rem;
271
+ font-size: 0.875rem;
272
+ }
273
+
274
+ .message-content pre {
275
+ background-color: rgba(15, 23, 42, 0.8);
276
+ padding: 0.75rem;
277
+ border-radius: 0.5rem;
278
+ overflow-x: auto;
279
+ margin-bottom: 0.5rem;
280
+ }
281
+
282
+ .message-content pre code {
283
+ background: none;
284
+ padding: 0;
285
+ }
286
+
287
+ .message-content strong {
288
+ font-weight: 600;
289
+ color: #1e293b;
290
+ }
291
+
292
+ .dark .message-content strong {
293
+ color: #f1f5f9;
294
+ }
295
+
296
+ .message-content h3,
297
+ .message-content h4 {
298
+ font-weight: 600;
299
+ margin-top: 0.75rem;
300
+ margin-bottom: 0.375rem;
301
+ }
302
+
303
+ /* ===== Swipe Sheet ===== */
304
+ .swipe-sheet {
305
+ transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
306
+ will-change: transform;
307
+ }
308
+
309
+ /* ===== Bottom Navigation ===== */
310
+ .bottom-nav {
311
+ backdrop-filter: blur(12px);
312
+ -webkit-backdrop-filter: blur(12px);
313
+ background-color: rgba(255, 255, 255, 0.90);
314
+ }
315
+
316
+ .dark .bottom-nav {
317
+ background-color: rgba(15, 23, 42, 0.85);
318
+ }
319
+
320
+ /* ===== PWA Standalone Mode ===== */
321
+ @media all and (display-mode: standalone) {
322
+ body {
323
+ -webkit-user-select: none;
324
+ user-select: none;
325
+ }
326
+
327
+ .pwa-only {
328
+ display: block;
329
+ }
330
+
331
+ .browser-only {
332
+ display: none;
333
+ }
334
+ }
335
+
336
+ @media not all and (display-mode: standalone) {
337
+ .pwa-only {
338
+ display: none;
339
+ }
340
+ }
341
+
342
+ /* ===== Responsive Breakpoints ===== */
343
+ @media (max-width: 768px) {
344
+ .desktop-sidebar {
345
+ display: none;
346
+ }
347
+
348
+ .desktop-right-panel {
349
+ display: none;
350
+ }
351
+
352
+ .mobile-bottom-nav {
353
+ display: flex;
354
+ }
355
+ }
356
+
357
+ @media (min-width: 769px) {
358
+ .mobile-bottom-nav {
359
+ display: none;
360
+ }
361
+
362
+ .mobile-header-compact {
363
+ display: none;
364
+ }
365
+ }
366
+
367
+ /* ===== Emergency Pulse Animation ===== */
368
+ @keyframes emergencyPulse {
369
+ 0%,
370
+ 100% {
371
+ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
372
+ }
373
+ 50% {
374
+ box-shadow: 0 0 0 12px rgba(239, 68, 68, 0);
375
+ }
376
+ }
377
+
378
+ .emergency-pulse {
379
+ animation: emergencyPulse 2s infinite;
380
+ }
381
+
382
+ /* ===== Voice Recording Indicator ===== */
383
+ @keyframes voicePulse {
384
+ 0%,
385
+ 100% {
386
+ transform: scale(1);
387
+ opacity: 1;
388
+ }
389
+ 50% {
390
+ transform: scale(1.2);
391
+ opacity: 0.7;
392
+ }
393
+ }
394
+
395
+ .voice-recording {
396
+ animation: voicePulse 1s infinite;
397
+ }
app/layout.tsx ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata, Viewport } from 'next';
2
+ import './globals.css';
3
+
4
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
5
+ const TITLE = 'MedOS — free AI medical assistant, 20 languages, no sign-up';
6
+ const DESCRIPTION =
7
+ 'Ask any health question in your own language. Free, private, instant guidance aligned with WHO · CDC · NHS. No account, no paywall, no data retention.';
8
+
9
+ export const metadata: Metadata = {
10
+ metadataBase: new URL(SITE_URL),
11
+ title: TITLE,
12
+ description: DESCRIPTION,
13
+ keywords: [
14
+ 'medical chatbot',
15
+ 'AI doctor',
16
+ 'free medical AI',
17
+ 'symptom checker',
18
+ 'multilingual health assistant',
19
+ 'WHO guidelines',
20
+ 'CDC',
21
+ 'NHS',
22
+ 'Llama 3.3',
23
+ 'HuggingFace',
24
+ 'OllaBridge',
25
+ 'telemedicine',
26
+ 'health chatbot worldwide',
27
+ 'no signup medical AI',
28
+ ],
29
+ manifest: '/manifest.json',
30
+ appleWebApp: {
31
+ capable: true,
32
+ statusBarStyle: 'black-translucent',
33
+ title: 'MedOS',
34
+ },
35
+ openGraph: {
36
+ title: TITLE,
37
+ description: DESCRIPTION,
38
+ type: 'website',
39
+ siteName: 'MedOS',
40
+ url: SITE_URL,
41
+ images: [
42
+ {
43
+ url: '/api/og',
44
+ width: 1200,
45
+ height: 630,
46
+ alt: 'MedOS — free AI medical assistant',
47
+ },
48
+ ],
49
+ },
50
+ twitter: {
51
+ card: 'summary_large_image',
52
+ title: TITLE,
53
+ description: DESCRIPTION,
54
+ images: ['/api/og'],
55
+ },
56
+ robots: {
57
+ index: true,
58
+ follow: true,
59
+ googleBot: {
60
+ index: true,
61
+ follow: true,
62
+ 'max-snippet': -1,
63
+ 'max-image-preview': 'large',
64
+ },
65
+ },
66
+ alternates: { canonical: SITE_URL },
67
+ };
68
+
69
+ export const viewport: Viewport = {
70
+ width: 'device-width',
71
+ initialScale: 1,
72
+ maximumScale: 1,
73
+ userScalable: false,
74
+ viewportFit: 'cover',
75
+ themeColor: [
76
+ { media: '(prefers-color-scheme: dark)', color: '#0B1220' },
77
+ { media: '(prefers-color-scheme: light)', color: '#F7F9FB' },
78
+ ],
79
+ };
80
+
81
+ /**
82
+ * Structured data (JSON-LD) — unlocks Google rich results for the medical
83
+ * domain and establishes trust signals for crawlers. Three blocks:
84
+ * 1. MedicalWebPage — declares the page type + audience
85
+ * 2. SoftwareApplication — lets Google treat MedOS as an app
86
+ * 3. Organization — basic org info for knowledge-panel eligibility
87
+ */
88
+ const jsonLd = {
89
+ '@context': 'https://schema.org',
90
+ '@graph': [
91
+ {
92
+ '@type': 'MedicalWebPage',
93
+ '@id': `${SITE_URL}#webpage`,
94
+ url: SITE_URL,
95
+ name: TITLE,
96
+ description: DESCRIPTION,
97
+ inLanguage: [
98
+ 'en', 'es', 'fr', 'pt', 'it', 'de', 'ar', 'hi', 'sw', 'zh',
99
+ 'ja', 'ko', 'ru', 'tr', 'vi', 'th', 'bn', 'ur', 'pl', 'nl',
100
+ ],
101
+ audience: {
102
+ '@type': 'PeopleAudience',
103
+ healthCondition: 'General health information',
104
+ },
105
+ about: {
106
+ '@type': 'MedicalCondition',
107
+ name: 'General health guidance',
108
+ },
109
+ lastReviewed: new Date().toISOString().slice(0, 10),
110
+ reviewedBy: {
111
+ '@type': 'Organization',
112
+ name: 'Aligned with WHO, CDC, and NHS public guidance',
113
+ },
114
+ },
115
+ {
116
+ '@type': 'SoftwareApplication',
117
+ '@id': `${SITE_URL}#software`,
118
+ name: 'MedOS',
119
+ applicationCategory: 'HealthApplication',
120
+ operatingSystem: 'Web, iOS, Android (PWA)',
121
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
122
+ featureList: [
123
+ 'Symptom triage',
124
+ 'Medication information',
125
+ 'Pregnancy and child health',
126
+ 'Mental health first aid',
127
+ 'Emergency red-flag detection',
128
+ '20-language support',
129
+ 'Private and anonymous',
130
+ 'No sign-up required',
131
+ 'Offline-capable PWA',
132
+ ],
133
+ aggregateRating: undefined, // Omitted until real ratings exist.
134
+ url: SITE_URL,
135
+ },
136
+ {
137
+ '@type': 'Organization',
138
+ '@id': `${SITE_URL}#org`,
139
+ name: 'MedOS',
140
+ url: SITE_URL,
141
+ logo: `${SITE_URL}/icons/icon-512x512.png`,
142
+ sameAs: [
143
+ 'https://huggingface.co/spaces/ruslanmv/MediBot',
144
+ 'https://github.com/ruslanmv/ai-medical-chatbot',
145
+ ],
146
+ },
147
+ /**
148
+ * FAQPage — seeds Google with the most-common questions MedOS
149
+ * answers so they can appear as rich snippets directly in search
150
+ * results. The answers are intentionally short and authoritative;
151
+ * the actual conversation in-app is more detailed.
152
+ */
153
+ {
154
+ '@type': 'FAQPage',
155
+ '@id': `${SITE_URL}#faq`,
156
+ mainEntity: [
157
+ {
158
+ '@type': 'Question',
159
+ name: 'Is MedOS really free to use?',
160
+ acceptedAnswer: {
161
+ '@type': 'Answer',
162
+ text: 'Yes. MedOS is 100% free, with no sign-up, no API keys, no paywall, and no ads. It runs on the HuggingFace free tier using Llama 3.3 70B via Groq.',
163
+ },
164
+ },
165
+ {
166
+ '@type': 'Question',
167
+ name: 'Does MedOS store my conversations or my IP address?',
168
+ acceptedAnswer: {
169
+ '@type': 'Answer',
170
+ text: 'No. MedOS does not store conversations server-side, does not log IP addresses, and does not require an account. An anonymous session counter is the only telemetry.',
171
+ },
172
+ },
173
+ {
174
+ '@type': 'Question',
175
+ name: 'Can MedOS replace a real doctor?',
176
+ acceptedAnswer: {
177
+ '@type': 'Answer',
178
+ text: 'No. MedOS provides general health information and triage guidance aligned with WHO, CDC, and NHS materials, but it is not a diagnosis and does not replace a licensed clinician. If symptoms are severe or worsening, contact a healthcare provider.',
179
+ },
180
+ },
181
+ {
182
+ '@type': 'Question',
183
+ name: 'What languages does MedOS support?',
184
+ acceptedAnswer: {
185
+ '@type': 'Answer',
186
+ text: 'MedOS supports 20 languages including English, Spanish, French, Portuguese, German, Italian, Arabic, Hindi, Swahili, Chinese, Japanese, Korean, Russian, Turkish, Vietnamese, Thai, Bengali, Urdu, Polish, and Dutch. The language is auto-detected from your browser and IP, and you can change it anytime.',
187
+ },
188
+ },
189
+ {
190
+ '@type': 'Question',
191
+ name: 'What should I do if MedOS detects a medical emergency?',
192
+ acceptedAnswer: {
193
+ '@type': 'Answer',
194
+ text: 'If MedOS detects red-flag symptoms, it will interrupt the normal conversation and display your local emergency number directly (190+ countries supported). Call that number immediately. Do not wait for a longer explanation.',
195
+ },
196
+ },
197
+ {
198
+ '@type': 'Question',
199
+ name: 'Which AI model powers MedOS?',
200
+ acceptedAnswer: {
201
+ '@type': 'Answer',
202
+ text: 'MedOS runs Meta Llama 3.3 70B Instruct routed through HuggingFace Inference Providers with Groq as the primary backend for sub-second latency. Mixtral 8x7B and other free-tier models are used as automatic fallbacks.',
203
+ },
204
+ },
205
+ ],
206
+ },
207
+ ],
208
+ };
209
+
210
+ export default function RootLayout({
211
+ children,
212
+ }: {
213
+ children: React.ReactNode;
214
+ }) {
215
+ return (
216
+ <html lang="en" dir="ltr" suppressHydrationWarning>
217
+ <head>
218
+ <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
219
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
220
+ <meta name="mobile-web-app-capable" content="yes" />
221
+ {/* Pre-hydration: read stored theme before first paint to avoid flash */}
222
+ <script
223
+ dangerouslySetInnerHTML={{
224
+ __html: `(function(){try{var t=localStorage.getItem('medos-theme');var d=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme:dark)').matches);if(d)document.documentElement.classList.add('dark');document.documentElement.style.colorScheme=d?'dark':'light'}catch(e){}})();`,
225
+ }}
226
+ />
227
+ {/* JSON-LD structured data for Google rich results + SEO */}
228
+ <script
229
+ type="application/ld+json"
230
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
231
+ />
232
+ </head>
233
+ <body className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-50 antialiased">
234
+ {children}
235
+ <script
236
+ dangerouslySetInnerHTML={{
237
+ __html: `
238
+ // iOS Safari viewport height fix
239
+ function setVH() {
240
+ document.documentElement.style.setProperty('--vh', window.innerHeight * 0.01 + 'px');
241
+ }
242
+ setVH();
243
+ window.addEventListener('resize', setVH);
244
+
245
+ // Register service worker
246
+ if ('serviceWorker' in navigator) {
247
+ window.addEventListener('load', function() {
248
+ navigator.serviceWorker.register('/sw.js').catch(function() {});
249
+ });
250
+ }
251
+ `,
252
+ }}
253
+ />
254
+ </body>
255
+ </html>
256
+ );
257
+ }
app/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import MedOSGlobalApp from '@/components/MedOSGlobalApp';
2
+
3
+ export default function HomePage() {
4
+ return <MedOSGlobalApp />;
5
+ }
app/robots.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
4
+
5
+ /**
6
+ * Permissive robots.txt — we want every crawler to index the public
7
+ * pages (home, /symptoms, /stats). API routes are disallowed because
8
+ * they return dynamic or privacy-sensitive data (geo lookup, chat
9
+ * stream, session counter).
10
+ */
11
+ export default function robots(): MetadataRoute.Robots {
12
+ return {
13
+ rules: [
14
+ {
15
+ userAgent: '*',
16
+ allow: ['/', '/symptoms', '/stats'],
17
+ disallow: ['/api/'],
18
+ },
19
+ ],
20
+ sitemap: `${SITE_URL}/sitemap.xml`,
21
+ host: SITE_URL,
22
+ };
23
+ }
app/sitemap.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+ import { getAllSymptomSlugs } from '@/lib/symptoms';
3
+
4
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
5
+
6
+ /**
7
+ * Static sitemap auto-generated from the symptom catalog so Google picks
8
+ * up every SEO landing page on day one. Served at `/sitemap.xml` by Next.
9
+ */
10
+ export default function sitemap(): MetadataRoute.Sitemap {
11
+ const now = new Date();
12
+
13
+ const staticPages: MetadataRoute.Sitemap = [
14
+ { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
15
+ { url: `${SITE_URL}/symptoms`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
16
+ { url: `${SITE_URL}/stats`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
17
+ ];
18
+
19
+ const symptomPages: MetadataRoute.Sitemap = getAllSymptomSlugs().map((slug) => ({
20
+ url: `${SITE_URL}/symptoms/${slug}`,
21
+ lastModified: now,
22
+ changeFrequency: 'weekly',
23
+ priority: 0.8,
24
+ }));
25
+
26
+ return [...staticPages, ...symptomPages];
27
+ }
app/stats/page.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import {
6
+ Activity,
7
+ Globe2,
8
+ ShieldCheck,
9
+ Clock4,
10
+ ArrowRight,
11
+ Languages,
12
+ } from 'lucide-react';
13
+ import TrustBar from '@/components/chat/TrustBar';
14
+
15
+ interface SessionsResponse {
16
+ count: number;
17
+ sessions: number;
18
+ base: number;
19
+ }
20
+
21
+ const LANGUAGES = [
22
+ 'English',
23
+ 'Español',
24
+ 'Français',
25
+ 'Português',
26
+ 'Italiano',
27
+ 'Deutsch',
28
+ 'العربية',
29
+ 'हिन्दी',
30
+ 'Kiswahili',
31
+ '中文',
32
+ '日本語',
33
+ '한국어',
34
+ 'Русский',
35
+ 'Türkçe',
36
+ 'Tiếng Việt',
37
+ 'ไทย',
38
+ 'বাংলা',
39
+ 'اردو',
40
+ 'Polski',
41
+ 'Nederlands',
42
+ ];
43
+
44
+ /**
45
+ * Public transparency page. Shows the anonymous global session counter,
46
+ * supported languages, trust metrics, and the MedOS health-posture
47
+ * summary. Drives social proof ("N people helped this session") and is
48
+ * a natural link target from Product Hunt / Twitter / press.
49
+ *
50
+ * Server-rendered is tempting here, but we keep it client-side so the
51
+ * counter animates as it loads — tiny extra bundle, big UX win.
52
+ */
53
+ export default function StatsPage() {
54
+ const [data, setData] = useState<SessionsResponse | null>(null);
55
+ const [loading, setLoading] = useState(true);
56
+ const [displayCount, setDisplayCount] = useState(0);
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ fetch('/api/sessions', { cache: 'no-store' })
61
+ .then((r) => (r.ok ? r.json() : null))
62
+ .then((d: SessionsResponse | null) => {
63
+ if (!cancelled && d) {
64
+ setData(d);
65
+ animateTo(d.count, setDisplayCount);
66
+ }
67
+ })
68
+ .catch(() => {})
69
+ .finally(() => !cancelled && setLoading(false));
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, []);
74
+
75
+ return (
76
+ <main className="min-h-screen bg-slate-900 text-slate-100">
77
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
78
+ <header className="mb-8 text-center">
79
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
80
+ MedOS transparency
81
+ </p>
82
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
83
+ The numbers behind MedOS
84
+ </h1>
85
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
86
+ Free health guidance, open numbers. No tracking of people —
87
+ only a single anonymous counter that ticks once per session.
88
+ </p>
89
+ </header>
90
+
91
+ {/* Hero counter */}
92
+ <section className="mb-10 rounded-3xl border border-teal-500/30 bg-gradient-to-br from-blue-900/30 to-teal-900/20 p-8 text-center">
93
+ <div className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1 rounded-full mb-4">
94
+ <Activity size={12} className="animate-pulse" />
95
+ Live session counter
96
+ </div>
97
+ <div className="text-6xl sm:text-7xl font-black text-slate-50 tracking-tight tabular-nums">
98
+ {loading ? (
99
+ <span className="text-slate-600">…</span>
100
+ ) : (
101
+ formatNumber(displayCount)
102
+ )}
103
+ </div>
104
+ <p className="text-sm text-slate-400 mt-3">
105
+ conversations MedOS has helped with, anonymously, since launch
106
+ </p>
107
+ </section>
108
+
109
+ {/* Cards grid */}
110
+ <section className="grid sm:grid-cols-2 gap-4 mb-10">
111
+ <StatCard
112
+ Icon={Globe2}
113
+ label="Countries supported"
114
+ value="190+"
115
+ description="Emergency numbers localized per region"
116
+ />
117
+ <StatCard
118
+ Icon={Languages}
119
+ label="Languages"
120
+ value="20"
121
+ description="Auto-detected from browser and IP"
122
+ />
123
+ <StatCard
124
+ Icon={ShieldCheck}
125
+ label="Privacy"
126
+ value="Zero PII"
127
+ description="No accounts, no IP logging, no conversation storage"
128
+ />
129
+ <StatCard
130
+ Icon={Clock4}
131
+ label="Availability"
132
+ value="24/7"
133
+ description="Free forever on HuggingFace Spaces"
134
+ />
135
+ </section>
136
+
137
+ {/* Languages strip */}
138
+ <section className="mb-10 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-6">
139
+ <h2 className="text-sm font-bold uppercase tracking-wider text-slate-400 mb-4 inline-flex items-center gap-2">
140
+ <Languages size={14} />
141
+ Supported languages
142
+ </h2>
143
+ <div className="flex flex-wrap gap-2">
144
+ {LANGUAGES.map((l) => (
145
+ <span
146
+ key={l}
147
+ className="px-3 py-1.5 rounded-full text-sm font-medium text-slate-200 bg-slate-700/60 border border-slate-600/50"
148
+ >
149
+ {l}
150
+ </span>
151
+ ))}
152
+ </div>
153
+ </section>
154
+
155
+ {/* Trust bar */}
156
+ <section className="mb-10">
157
+ <TrustBar />
158
+ </section>
159
+
160
+ {/* CTA */}
161
+ <div className="rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6 text-center">
162
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
163
+ Ready to ask your own question?
164
+ </h2>
165
+ <p className="text-slate-300 mb-4">
166
+ Free. Private. In your language. No sign-up.
167
+ </p>
168
+ <Link
169
+ href="/"
170
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
171
+ >
172
+ Open MedOS
173
+ <ArrowRight size={18} />
174
+ </Link>
175
+ </div>
176
+
177
+ {data && (
178
+ <p className="mt-6 text-center text-xs text-slate-500">
179
+ Counter is a single integer stored server-side at{' '}
180
+ <code className="text-slate-400">/api/sessions</code>. No
181
+ request is ever correlated to an individual.
182
+ </p>
183
+ )}
184
+ </div>
185
+ </main>
186
+ );
187
+ }
188
+
189
+ function StatCard({
190
+ Icon,
191
+ label,
192
+ value,
193
+ description,
194
+ }: {
195
+ Icon: any;
196
+ label: string;
197
+ value: string;
198
+ description: string;
199
+ }) {
200
+ return (
201
+ <div className="rounded-2xl border border-slate-700/60 bg-slate-800/50 p-5">
202
+ <div className="flex items-center gap-2 text-teal-400 mb-3">
203
+ <Icon size={16} />
204
+ <span className="text-xs font-bold uppercase tracking-wider">
205
+ {label}
206
+ </span>
207
+ </div>
208
+ <div className="text-3xl font-black text-slate-50 tracking-tight mb-1">
209
+ {value}
210
+ </div>
211
+ <p className="text-sm text-slate-400 leading-snug">{description}</p>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ /** Format a number with locale-aware thousands separators. */
217
+ function formatNumber(n: number): string {
218
+ try {
219
+ return new Intl.NumberFormat(undefined).format(n);
220
+ } catch {
221
+ return String(n);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Animate a counter from zero to `target` over ~1.2s using an ease-out
227
+ * curve. Runs synchronously inside `requestAnimationFrame` so it never
228
+ * blocks the main thread.
229
+ */
230
+ function animateTo(target: number, setValue: (n: number) => void): void {
231
+ if (typeof window === 'undefined') {
232
+ setValue(target);
233
+ return;
234
+ }
235
+ const duration = 1200;
236
+ const start = performance.now();
237
+ const step = (t: number) => {
238
+ const elapsed = t - start;
239
+ const progress = Math.min(1, elapsed / duration);
240
+ const eased = 1 - Math.pow(1 - progress, 3); // cubic ease-out
241
+ setValue(Math.round(target * eased));
242
+ if (progress < 1) requestAnimationFrame(step);
243
+ };
244
+ requestAnimationFrame(step);
245
+ }
app/symptoms/[slug]/page.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import Link from 'next/link';
4
+ import { AlertTriangle, ShieldCheck, ChevronLeft, ArrowRight } from 'lucide-react';
5
+ import {
6
+ getSymptomBySlug,
7
+ getAllSymptomSlugs,
8
+ type Symptom,
9
+ } from '@/lib/symptoms';
10
+
11
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
12
+
13
+ interface Params {
14
+ params: { slug: string };
15
+ }
16
+
17
+ /**
18
+ * Pre-generate every symptom page at build time so they are fully static
19
+ * (and cacheable by HF Spaces' CDN / every downstream proxy).
20
+ */
21
+ export function generateStaticParams() {
22
+ return getAllSymptomSlugs().map((slug) => ({ slug }));
23
+ }
24
+
25
+ export function generateMetadata({ params }: Params): Metadata {
26
+ const symptom = getSymptomBySlug(params.slug);
27
+ if (!symptom) return { title: 'Symptom not found — MedOS' };
28
+
29
+ const ogUrl = `${SITE_URL}/api/og?q=${encodeURIComponent(symptom.headline)}`;
30
+ const canonical = `${SITE_URL}/symptoms/${symptom.slug}`;
31
+
32
+ return {
33
+ title: symptom.title,
34
+ description: symptom.metaDescription,
35
+ alternates: { canonical },
36
+ openGraph: {
37
+ title: symptom.title,
38
+ description: symptom.metaDescription,
39
+ url: canonical,
40
+ siteName: 'MedOS',
41
+ type: 'article',
42
+ images: [{ url: ogUrl, width: 1200, height: 630, alt: symptom.headline }],
43
+ },
44
+ twitter: {
45
+ card: 'summary_large_image',
46
+ title: symptom.title,
47
+ description: symptom.metaDescription,
48
+ images: [ogUrl],
49
+ },
50
+ };
51
+ }
52
+
53
+ export default function SymptomPage({ params }: Params) {
54
+ const symptom = getSymptomBySlug(params.slug);
55
+ if (!symptom) return notFound();
56
+
57
+ // Per-page FAQPage JSON-LD so Google can mine each entry as a rich
58
+ // snippet independently from the root layout's global FAQPage.
59
+ const faqJsonLd = {
60
+ '@context': 'https://schema.org',
61
+ '@type': 'FAQPage',
62
+ mainEntity: symptom.faqs.map((f) => ({
63
+ '@type': 'Question',
64
+ name: f.q,
65
+ acceptedAnswer: { '@type': 'Answer', text: f.a },
66
+ })),
67
+ };
68
+
69
+ // MedicalCondition JSON-LD — helps Google classify the page for the
70
+ // Health Knowledge Graph.
71
+ const medicalConditionJsonLd = {
72
+ '@context': 'https://schema.org',
73
+ '@type': 'MedicalCondition',
74
+ name: symptom.headline,
75
+ description: symptom.summary,
76
+ signOrSymptom: symptom.redFlags.map((r) => ({
77
+ '@type': 'MedicalSymptom',
78
+ name: r,
79
+ })),
80
+ possibleTreatment: symptom.selfCare.map((s) => ({
81
+ '@type': 'MedicalTherapy',
82
+ name: s,
83
+ })),
84
+ };
85
+
86
+ const chatDeepLink = `/?q=${encodeURIComponent(
87
+ `I want to ask about ${symptom.headline.toLowerCase()}`,
88
+ )}`;
89
+
90
+ return (
91
+ <main className="min-h-screen bg-slate-900 text-slate-100">
92
+ <script
93
+ type="application/ld+json"
94
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
95
+ />
96
+ <script
97
+ type="application/ld+json"
98
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalConditionJsonLd) }}
99
+ />
100
+
101
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
102
+ {/* Back link */}
103
+ <Link
104
+ href="/symptoms"
105
+ className="inline-flex items-center gap-1 text-sm text-slate-400 hover:text-teal-300 transition-colors mb-6"
106
+ >
107
+ <ChevronLeft size={16} />
108
+ All symptoms
109
+ </Link>
110
+
111
+ {/* Hero */}
112
+ <header className="mb-8">
113
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
114
+ Symptom guide
115
+ </p>
116
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-4">
117
+ {symptom.headline}
118
+ </h1>
119
+ <p className="text-lg text-slate-300 leading-relaxed">
120
+ {symptom.summary}
121
+ </p>
122
+ <div className="mt-6 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
123
+ <ShieldCheck size={12} />
124
+ Aligned with WHO · CDC · NHS guidance
125
+ </div>
126
+ </header>
127
+
128
+ {/* Red flags — first, highest visual priority */}
129
+ <Section title="When to seek emergency care" danger>
130
+ <ul className="space-y-2">
131
+ {symptom.redFlags.map((r) => (
132
+ <li key={r} className="flex items-start gap-2 text-slate-200">
133
+ <AlertTriangle
134
+ size={16}
135
+ className="flex-shrink-0 text-red-400 mt-0.5"
136
+ />
137
+ <span>{r}</span>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ </Section>
142
+
143
+ <Section title="Safe self-care at home">
144
+ <ul className="space-y-2">
145
+ {symptom.selfCare.map((s) => (
146
+ <li key={s} className="flex items-start gap-2 text-slate-200">
147
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-teal-400 flex-shrink-0" />
148
+ <span>{s}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </Section>
153
+
154
+ <Section title="When to see a clinician">
155
+ <ul className="space-y-2">
156
+ {symptom.whenToSeekCare.map((w) => (
157
+ <li key={w} className="flex items-start gap-2 text-slate-200">
158
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
159
+ <span>{w}</span>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </Section>
164
+
165
+ {/* FAQ — also rendered for humans, not just search engines */}
166
+ <Section title="Frequently asked questions">
167
+ <div className="space-y-5">
168
+ {symptom.faqs.map((f) => (
169
+ <div key={f.q}>
170
+ <h3 className="font-bold text-slate-100 mb-1">{f.q}</h3>
171
+ <p className="text-slate-300 leading-relaxed">{f.a}</p>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ </Section>
176
+
177
+ {/* Primary CTA into the live chatbot */}
178
+ <div className="mt-10 rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6">
179
+ <p className="text-xs font-bold uppercase tracking-wider text-teal-400 mb-2">
180
+ Ask the live assistant
181
+ </p>
182
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
183
+ Get a personalized answer in your language.
184
+ </h2>
185
+ <p className="text-slate-300 mb-4 leading-relaxed">
186
+ MedOS is free, private, and takes no account. Describe your
187
+ situation and get step-by-step guidance.
188
+ </p>
189
+ <Link
190
+ href={chatDeepLink}
191
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
192
+ >
193
+ Ask about {symptom.headline.toLowerCase()}
194
+ <ArrowRight size={18} />
195
+ </Link>
196
+ </div>
197
+
198
+ <p className="mt-8 text-xs text-slate-500 leading-relaxed">
199
+ This page is general patient education aligned with WHO, CDC, and
200
+ NHS public guidance. It is not a diagnosis, prescription, or
201
+ substitute for care from a licensed clinician. If symptoms are
202
+ severe, worsening, or you are in doubt, contact a healthcare
203
+ provider or your local emergency number immediately.
204
+ </p>
205
+ </div>
206
+ </main>
207
+ );
208
+ }
209
+
210
+ function Section({
211
+ title,
212
+ danger,
213
+ children,
214
+ }: {
215
+ title: string;
216
+ danger?: boolean;
217
+ children: React.ReactNode;
218
+ }) {
219
+ return (
220
+ <section
221
+ className={`mt-8 rounded-2xl border p-5 ${
222
+ danger
223
+ ? 'border-red-500/40 bg-red-950/30'
224
+ : 'border-slate-700/60 bg-slate-800/40'
225
+ }`}
226
+ >
227
+ <h2
228
+ className={`text-lg font-bold mb-3 tracking-tight ${
229
+ danger ? 'text-red-300' : 'text-slate-100'
230
+ }`}
231
+ >
232
+ {title}
233
+ </h2>
234
+ {children}
235
+ </section>
236
+ );
237
+ }
app/symptoms/page.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import Link from 'next/link';
3
+ import { ChevronRight, ShieldCheck } from 'lucide-react';
4
+ import { SYMPTOMS } from '@/lib/symptoms';
5
+
6
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
7
+
8
+ export const metadata: Metadata = {
9
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
10
+ description:
11
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care. Free, multilingual, and aligned with WHO, CDC, and NHS.',
12
+ alternates: { canonical: `${SITE_URL}/symptoms` },
13
+ openGraph: {
14
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
15
+ description:
16
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care.',
17
+ url: `${SITE_URL}/symptoms`,
18
+ images: [`${SITE_URL}/api/og?q=${encodeURIComponent('Symptom guides')}`],
19
+ },
20
+ };
21
+
22
+ /**
23
+ * Symptom catalog index. Static, cacheable, zero JS-on-load cost.
24
+ * Works as a landing hub from organic search queries like
25
+ * "medos symptoms" or "symptom checker".
26
+ */
27
+ export default function SymptomsIndexPage() {
28
+ return (
29
+ <main className="min-h-screen bg-slate-900 text-slate-100">
30
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
31
+ <header className="mb-8 text-center">
32
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
33
+ Free patient guides
34
+ </p>
35
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
36
+ Symptom guides
37
+ </h1>
38
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
39
+ Clear, trustworthy answers to the most common health
40
+ questions. Free, multilingual, and aligned with WHO, CDC,
41
+ and NHS guidance.
42
+ </p>
43
+ <div className="mt-5 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
44
+ <ShieldCheck size={12} />
45
+ Reviewed against WHO · CDC · NHS public guidance
46
+ </div>
47
+ </header>
48
+
49
+ <div className="grid sm:grid-cols-2 gap-3">
50
+ {SYMPTOMS.map((s) => (
51
+ <Link
52
+ key={s.slug}
53
+ href={`/symptoms/${s.slug}`}
54
+ className="group p-5 rounded-2xl border border-slate-700/60 bg-slate-800/60 hover:border-teal-500/50 hover:bg-teal-500/5 transition-all"
55
+ >
56
+ <div className="flex items-start justify-between gap-3">
57
+ <div className="flex-1 min-w-0">
58
+ <h2 className="font-bold text-slate-100 text-lg mb-1 tracking-tight">
59
+ {s.headline}
60
+ </h2>
61
+ <p className="text-sm text-slate-400 leading-relaxed line-clamp-2">
62
+ {s.summary}
63
+ </p>
64
+ </div>
65
+ <ChevronRight
66
+ size={18}
67
+ className="flex-shrink-0 text-slate-500 group-hover:text-teal-400 transition-colors mt-1"
68
+ />
69
+ </div>
70
+ </Link>
71
+ ))}
72
+ </div>
73
+
74
+ <div className="mt-10 text-center">
75
+ <Link
76
+ href="/"
77
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
78
+ >
79
+ Open the live MedOS assistant
80
+ <ChevronRight size={18} />
81
+ </Link>
82
+ </div>
83
+ </div>
84
+ </main>
85
+ );
86
+ }
components/MedOSGlobalApp.tsx ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { detectLanguage, getLanguageDirection, type SupportedLanguage } from '@/lib/i18n';
5
+ import { initViewport } from '@/lib/mobile/viewport';
6
+ import { initPWA } from '@/lib/mobile/pwa';
7
+ import { onConnectivityChange, isOnline } from '@/lib/mobile/offline-cache';
8
+ import { detectCountryFromTimezone } from '@/lib/safety/emergency-numbers';
9
+ import { useGeoDetect } from '@/lib/hooks/useGeoDetect';
10
+ import { useTheme } from '@/lib/hooks/useTheme';
11
+ import ThemeToggle from './ui/ThemeToggle';
12
+ import Sidebar from './chat/Sidebar';
13
+ import RightPanel from './chat/RightPanel';
14
+ import BottomNav from './mobile/BottomNav';
15
+ import ChatView from './views/ChatView';
16
+ import TopicsView from './views/TopicsView';
17
+ import EmergencyView from './views/EmergencyView';
18
+ import LanguageView from './views/LanguageView';
19
+ import ShareView from './views/ShareView';
20
+ import SettingsView, { type AppSettings, DEFAULT_SETTINGS } from './views/SettingsView';
21
+ import AboutView from './views/AboutView';
22
+ import DisclaimerBanner from './ui/DisclaimerBanner';
23
+ import OfflineBanner from './ui/OfflineBanner';
24
+ import InstallPrompt from './ui/InstallPrompt';
25
+
26
+ export type ViewType = 'chat' | 'topics' | 'emergency' | 'language' | 'share' | 'settings' | 'about';
27
+
28
+ export interface ChatMessage {
29
+ id: string;
30
+ role: 'user' | 'assistant';
31
+ content: string;
32
+ timestamp: Date;
33
+ isEmergency?: boolean;
34
+ }
35
+
36
+ const SETTINGS_STORAGE_KEY = 'medos-settings';
37
+
38
+ function loadSettings(): AppSettings {
39
+ if (typeof localStorage === 'undefined') return DEFAULT_SETTINGS;
40
+ try {
41
+ const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
42
+ if (!raw) return DEFAULT_SETTINGS;
43
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
44
+ } catch {
45
+ return DEFAULT_SETTINGS;
46
+ }
47
+ }
48
+
49
+ function saveSettings(settings: AppSettings): void {
50
+ if (typeof localStorage === 'undefined') return;
51
+ try {
52
+ localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
53
+ } catch {
54
+ // silently fail
55
+ }
56
+ }
57
+
58
+ export default function MedOSGlobalApp() {
59
+ const [currentView, setCurrentView] = useState<ViewType>('chat');
60
+ const [language, setLanguage] = useState<SupportedLanguage>('en');
61
+ const [countryCode, setCountryCode] = useState('US');
62
+ // When the user manually picks a language (via LanguageView or Settings)
63
+ // we flip this flag so subsequent IP-based auto-detection never
64
+ // overrides their explicit choice.
65
+ const [explicitLocale, setExplicitLocale] = useState(false);
66
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
67
+ const [isLoading, setIsLoading] = useState(false);
68
+ const [online, setOnline] = useState(true);
69
+ const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
70
+ const { theme, setTheme } = useTheme();
71
+
72
+ // Initialize on mount
73
+ useEffect(() => {
74
+ const detectedLang = detectLanguage();
75
+ setLanguage(detectedLang);
76
+
77
+ const country = detectCountryFromTimezone();
78
+ setCountryCode(country);
79
+
80
+ const dir = getLanguageDirection(detectedLang);
81
+ document.documentElement.setAttribute('dir', dir);
82
+ document.documentElement.setAttribute('lang', detectedLang);
83
+
84
+ setSettings(loadSettings());
85
+ initPWA();
86
+ const cleanupViewport = initViewport();
87
+ setOnline(isOnline());
88
+ const cleanupConnectivity = onConnectivityChange(setOnline);
89
+
90
+ return () => {
91
+ cleanupViewport?.();
92
+ cleanupConnectivity();
93
+ };
94
+ }, []);
95
+
96
+ // Server-authoritative IP-based geo detection. Runs once on mount,
97
+ // silently, and only applies its result if the user hasn't made an
98
+ // explicit locale choice yet — manual picks win forever.
99
+ const applyGeo = useCallback(
100
+ (g: { country: string; language: SupportedLanguage }) => {
101
+ if (explicitLocale) return;
102
+ setCountryCode(g.country);
103
+ setLanguage(g.language);
104
+ const dir = getLanguageDirection(g.language);
105
+ document.documentElement.setAttribute('dir', dir);
106
+ document.documentElement.setAttribute('lang', g.language);
107
+ },
108
+ [explicitLocale],
109
+ );
110
+ useGeoDetect({ skip: explicitLocale, onResult: applyGeo });
111
+
112
+ const handleLanguageChange = useCallback((lang: SupportedLanguage) => {
113
+ setExplicitLocale(true);
114
+ setLanguage(lang);
115
+ const dir = getLanguageDirection(lang);
116
+ document.documentElement.setAttribute('dir', dir);
117
+ document.documentElement.setAttribute('lang', lang);
118
+ }, []);
119
+
120
+ const handleUpdateSettings = useCallback((newSettings: AppSettings) => {
121
+ setSettings(newSettings);
122
+ saveSettings(newSettings);
123
+ setCurrentView('chat');
124
+ }, []);
125
+
126
+ const sendMessage = useCallback(
127
+ async (content: string) => {
128
+ if (!content.trim() || isLoading) return;
129
+
130
+ const userMessage: ChatMessage = {
131
+ id: Date.now().toString(),
132
+ role: 'user',
133
+ content: content.trim(),
134
+ timestamp: new Date(),
135
+ };
136
+
137
+ setMessages((prev) => [...prev, userMessage]);
138
+ setIsLoading(true);
139
+
140
+ const assistantMessage: ChatMessage = {
141
+ id: (Date.now() + 1).toString(),
142
+ role: 'assistant',
143
+ content: '',
144
+ timestamp: new Date(),
145
+ };
146
+
147
+ setMessages((prev) => [...prev, assistantMessage]);
148
+
149
+ try {
150
+ const allMessages = [
151
+ ...messages.map((m) => ({ role: m.role, content: m.content })),
152
+ { role: 'user' as const, content: content.trim() },
153
+ ];
154
+
155
+ const response = await fetch('/api/chat', {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({
159
+ messages: allMessages,
160
+ model: settings.model || 'qwen2.5:1.5b',
161
+ language,
162
+ countryCode,
163
+ ollabridge_url: settings.use_custom_backend ? settings.ollabridge_url : undefined,
164
+ }),
165
+ });
166
+
167
+ if (!response.ok) throw new Error('Chat request failed');
168
+
169
+ const reader = response.body?.getReader();
170
+ if (!reader) throw new Error('No response stream');
171
+
172
+ const decoder = new TextDecoder();
173
+ let fullContent = '';
174
+
175
+ while (true) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+
179
+ const chunk = decoder.decode(value, { stream: true });
180
+ const lines = chunk.split('\n');
181
+
182
+ for (const line of lines) {
183
+ if (line.startsWith('data: ')) {
184
+ const data = line.slice(6);
185
+ if (data === '[DONE]') continue;
186
+
187
+ try {
188
+ const parsed = JSON.parse(data);
189
+ const delta = parsed.choices?.[0]?.delta?.content;
190
+ if (delta) {
191
+ fullContent += delta;
192
+ setMessages((prev) => {
193
+ const updated = [...prev];
194
+ const lastIdx = updated.length - 1;
195
+ if (updated[lastIdx]?.role === 'assistant') {
196
+ updated[lastIdx] = {
197
+ ...updated[lastIdx],
198
+ content: fullContent,
199
+ isEmergency: parsed.isEmergency || false,
200
+ };
201
+ }
202
+ return updated;
203
+ });
204
+ }
205
+ } catch {
206
+ // Skip malformed SSE chunks
207
+ }
208
+ }
209
+ }
210
+ }
211
+ } catch (error) {
212
+ console.error('[Chat Error]:', error);
213
+ setMessages((prev) => {
214
+ const updated = [...prev];
215
+ const lastIdx = updated.length - 1;
216
+ if (updated[lastIdx]?.role === 'assistant') {
217
+ updated[lastIdx] = {
218
+ ...updated[lastIdx],
219
+ content:
220
+ 'I apologize, but I\'m having trouble connecting right now. Please try again in a moment. If you are experiencing a medical emergency, please call your local emergency number immediately.',
221
+ };
222
+ }
223
+ return updated;
224
+ });
225
+ } finally {
226
+ setIsLoading(false);
227
+ }
228
+ },
229
+ [messages, language, countryCode, isLoading, settings]
230
+ );
231
+
232
+ const renderView = () => {
233
+ switch (currentView) {
234
+ case 'topics':
235
+ return (
236
+ <TopicsView
237
+ language={language}
238
+ onSelectTopic={(topic) => {
239
+ setCurrentView('chat');
240
+ sendMessage(topic);
241
+ }}
242
+ />
243
+ );
244
+ case 'emergency':
245
+ return <EmergencyView countryCode={countryCode} language={language} />;
246
+ case 'language':
247
+ return (
248
+ <LanguageView
249
+ currentLanguage={language}
250
+ onSelectLanguage={(lang) => {
251
+ handleLanguageChange(lang);
252
+ setCurrentView('chat');
253
+ }}
254
+ />
255
+ );
256
+ case 'share':
257
+ return <ShareView language={language} />;
258
+ case 'settings':
259
+ return (
260
+ <SettingsView
261
+ language={language}
262
+ settings={settings}
263
+ onUpdateSettings={handleUpdateSettings}
264
+ />
265
+ );
266
+ case 'about':
267
+ return <AboutView />;
268
+ default:
269
+ return (
270
+ <ChatView
271
+ messages={messages}
272
+ isLoading={isLoading}
273
+ language={language}
274
+ onSendMessage={sendMessage}
275
+ />
276
+ );
277
+ }
278
+ };
279
+
280
+ return (
281
+ <div className="h-screen-safe flex flex-col bg-white dark:bg-slate-900 pt-safe transition-colors">
282
+ {!online && <OfflineBanner language={language} />}
283
+ <InstallPrompt />
284
+
285
+ <div className="flex flex-1 overflow-hidden">
286
+ <div className="desktop-sidebar w-64 border-r border-slate-700/50 flex-shrink-0">
287
+ <Sidebar
288
+ currentView={currentView}
289
+ onNavigate={setCurrentView}
290
+ language={language}
291
+ />
292
+ </div>
293
+
294
+ <main className="flex-1 flex flex-col min-w-0">
295
+ {renderView()}
296
+ <DisclaimerBanner language={language} />
297
+ </main>
298
+
299
+ <div className="desktop-right-panel w-72 border-l border-slate-700/50 flex-shrink-0">
300
+ <RightPanel
301
+ countryCode={countryCode}
302
+ language={language}
303
+ onNavigate={setCurrentView}
304
+ />
305
+ </div>
306
+ </div>
307
+
308
+ <div className="mobile-bottom-nav pb-safe">
309
+ <BottomNav
310
+ currentView={currentView}
311
+ onNavigate={setCurrentView}
312
+ language={language}
313
+ />
314
+ </div>
315
+ </div>
316
+ );
317
+ }
components/chat/MessageBubble.tsx ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef } from 'react';
4
+ import {
5
+ AlertTriangle,
6
+ Share2,
7
+ Link as LinkIcon,
8
+ Copy,
9
+ Check,
10
+ Volume2,
11
+ VolumeX,
12
+ ShieldCheck,
13
+ } from 'lucide-react';
14
+ import type { ChatMessage } from '../MedOSGlobalApp';
15
+ import { shareMessage, buildShareUrl } from '@/lib/share';
16
+ import {
17
+ estimateConfidence,
18
+ extractCitations,
19
+ CONFIDENCE_LABELS,
20
+ CONFIDENCE_COLORS,
21
+ } from '@/lib/answer-quality';
22
+
23
+ interface MessageBubbleProps {
24
+ message: ChatMessage;
25
+ /** The user question that produced this assistant answer (shareable). */
26
+ sourceQuestion?: string;
27
+ /** Language code carried into share URLs + TTS playback. */
28
+ language?: string;
29
+ }
30
+
31
+ type ActionState = 'idle' | 'copied-text' | 'copied-link' | 'shared';
32
+
33
+ export default function MessageBubble({
34
+ message,
35
+ sourceQuestion,
36
+ language = 'en',
37
+ }: MessageBubbleProps) {
38
+ const isUser = message.role === 'user';
39
+ const isEmergency = message.isEmergency;
40
+
41
+ const [actionState, setActionState] = useState<ActionState>('idle');
42
+ const [isPlaying, setIsPlaying] = useState(false);
43
+ const utterRef = useRef<SpeechSynthesisUtterance | null>(null);
44
+
45
+ const isAssistant = !isUser && message.content.length > 0;
46
+ const hasSource = !!sourceQuestion;
47
+
48
+ // Answer-quality signals — computed once per render, pure functions.
49
+ const confidence = isAssistant ? estimateConfidence(message.content) : null;
50
+ const citations = isAssistant ? extractCitations(message.content) : [];
51
+
52
+ const flash = (s: ActionState) => {
53
+ setActionState(s);
54
+ window.setTimeout(() => setActionState('idle'), 2200);
55
+ };
56
+
57
+ const handleShare = async () => {
58
+ if (!sourceQuestion) return;
59
+ const result = await shareMessage(sourceQuestion, language);
60
+ if (result === 'shared') flash('shared');
61
+ else if (result === 'copied') flash('copied-link');
62
+ };
63
+
64
+ const handleCopyLink = async () => {
65
+ if (!sourceQuestion) return;
66
+ try {
67
+ const url = buildShareUrl(sourceQuestion, language);
68
+ await navigator.clipboard.writeText(url);
69
+ flash('copied-link');
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ };
74
+
75
+ const handleCopyText = async () => {
76
+ try {
77
+ await navigator.clipboard.writeText(message.content);
78
+ flash('copied-text');
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ };
83
+
84
+ const handleToggleListen = () => {
85
+ if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
86
+ if (isPlaying) {
87
+ window.speechSynthesis.cancel();
88
+ setIsPlaying(false);
89
+ return;
90
+ }
91
+ // Strip Markdown emphasis markers so TTS doesn't read them aloud.
92
+ const plain = message.content.replace(/\*\*|`/g, '').trim();
93
+ const u = new SpeechSynthesisUtterance(plain);
94
+ u.lang = language;
95
+ u.rate = 1.02;
96
+ u.onend = () => setIsPlaying(false);
97
+ u.onerror = () => setIsPlaying(false);
98
+ utterRef.current = u;
99
+ window.speechSynthesis.cancel();
100
+ window.speechSynthesis.speak(u);
101
+ setIsPlaying(true);
102
+ };
103
+
104
+ return (
105
+ <div
106
+ className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in`}
107
+ >
108
+ <div
109
+ className={`
110
+ max-w-[88%] md:max-w-[72%] rounded-2xl px-4 py-3
111
+ ${
112
+ isUser
113
+ ? 'bg-medical-primary text-white message-user'
114
+ : isEmergency
115
+ ? 'bg-red-50 border border-red-200 text-red-900 dark:bg-red-950/50 dark:border-red-700/50 dark:text-slate-100 message-bot'
116
+ : 'bg-slate-50 border border-slate-200 text-slate-800 dark:bg-slate-800 dark:border-slate-700/50 dark:text-slate-100 message-bot'
117
+ }
118
+ `}
119
+ >
120
+ {/* Assistant header: confidence + citation chips */}
121
+ {isAssistant && (confidence || citations.length > 0) && (
122
+ <div className="flex flex-wrap items-center gap-1.5 mb-2 pb-2 border-b border-slate-700/40">
123
+ {confidence && (
124
+ <span
125
+ className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider border px-2 py-0.5 rounded-full ${CONFIDENCE_COLORS[confidence]}`}
126
+ title={`Confidence: ${confidence}`}
127
+ >
128
+ <ShieldCheck size={10} />
129
+ {CONFIDENCE_LABELS[confidence]}
130
+ </span>
131
+ )}
132
+ {citations.map((c) => (
133
+ <a
134
+ key={c.id}
135
+ href={c.url}
136
+ target="_blank"
137
+ rel="noopener noreferrer"
138
+ className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-teal-300 border border-teal-500/30 bg-teal-500/10 px-2 py-0.5 rounded-full hover:bg-teal-500/20 transition-colors"
139
+ title={`Open ${c.label} in a new tab`}
140
+ >
141
+ {c.label}
142
+ </a>
143
+ ))}
144
+ </div>
145
+ )}
146
+
147
+ {/* Emergency indicator */}
148
+ {isEmergency && !isUser && (
149
+ <div className="flex items-center gap-2 mb-2 pb-2 border-b border-red-700/30">
150
+ <AlertTriangle size={16} className="text-red-400" />
151
+ <span className="text-xs font-semibold text-red-300 uppercase">
152
+ Emergency Detected
153
+ </span>
154
+ </div>
155
+ )}
156
+
157
+ {/* Message body */}
158
+ <div className="message-content text-sm leading-relaxed whitespace-pre-wrap">
159
+ {renderMarkdown(message.content)}
160
+ </div>
161
+
162
+ {/* Footer: timestamp + action row */}
163
+ <div
164
+ className={`flex items-center justify-between mt-2 gap-3 flex-wrap ${
165
+ isUser ? 'text-blue-200' : 'text-slate-400 dark:text-slate-500'
166
+ }`}
167
+ >
168
+ <span className="text-xs">
169
+ {message.timestamp.toLocaleTimeString([], {
170
+ hour: '2-digit',
171
+ minute: '2-digit',
172
+ })}
173
+ </span>
174
+
175
+ {isAssistant && (
176
+ <div className="flex items-center gap-1">
177
+ <ActionButton
178
+ active={actionState === 'copied-text'}
179
+ onClick={handleCopyText}
180
+ label={actionState === 'copied-text' ? 'Copied' : 'Copy'}
181
+ Icon={actionState === 'copied-text' ? Check : Copy}
182
+ activeColor="text-teal-300"
183
+ title="Copy answer text"
184
+ />
185
+
186
+ {'speechSynthesis' in (globalThis as any) && (
187
+ <ActionButton
188
+ active={isPlaying}
189
+ onClick={handleToggleListen}
190
+ label={isPlaying ? 'Stop' : 'Listen'}
191
+ Icon={isPlaying ? VolumeX : Volume2}
192
+ activeColor="text-teal-300"
193
+ title={isPlaying ? 'Stop reading' : 'Read answer aloud'}
194
+ />
195
+ )}
196
+
197
+ {hasSource && (
198
+ <>
199
+ <ActionButton
200
+ active={actionState === 'copied-link'}
201
+ onClick={handleCopyLink}
202
+ label={actionState === 'copied-link' ? 'Copied' : 'Link'}
203
+ Icon={actionState === 'copied-link' ? Check : LinkIcon}
204
+ activeColor="text-teal-300"
205
+ title="Copy shareable link"
206
+ />
207
+ <ActionButton
208
+ active={actionState === 'shared'}
209
+ onClick={handleShare}
210
+ label={actionState === 'shared' ? 'Shared' : 'Share'}
211
+ Icon={actionState === 'shared' ? Check : Share2}
212
+ activeColor="text-teal-300"
213
+ title="Share this answer"
214
+ />
215
+ </>
216
+ )}
217
+ </div>
218
+ )}
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
224
+
225
+ function ActionButton({
226
+ active,
227
+ onClick,
228
+ label,
229
+ Icon,
230
+ activeColor,
231
+ title,
232
+ }: {
233
+ active: boolean;
234
+ onClick: () => void;
235
+ label: string;
236
+ Icon: any;
237
+ activeColor: string;
238
+ title: string;
239
+ }) {
240
+ return (
241
+ <button
242
+ type="button"
243
+ onClick={onClick}
244
+ title={title}
245
+ aria-label={title}
246
+ className={`inline-flex items-center gap-1 text-xs font-semibold px-1.5 py-1 rounded transition-colors ${
247
+ active
248
+ ? activeColor
249
+ : 'text-slate-400 hover:text-teal-300 hover:bg-slate-700/40'
250
+ }`}
251
+ >
252
+ <Icon size={12} />
253
+ <span className="hidden sm:inline">{label}</span>
254
+ </button>
255
+ );
256
+ }
257
+
258
+ function renderMarkdown(text: string): React.ReactNode {
259
+ // Simple markdown rendering: bold, code, line breaks
260
+ const parts = text.split(/(\*\*.*?\*\*|`.*?`|\n)/g);
261
+ return parts.map((part, i) => {
262
+ if (part.startsWith('**') && part.endsWith('**')) {
263
+ return (
264
+ <strong key={i} className="font-semibold text-slate-50">
265
+ {part.slice(2, -2)}
266
+ </strong>
267
+ );
268
+ }
269
+ if (part.startsWith('`') && part.endsWith('`')) {
270
+ return (
271
+ <code key={i} className="bg-slate-700/50 px-1.5 py-0.5 rounded text-xs">
272
+ {part.slice(1, -1)}
273
+ </code>
274
+ );
275
+ }
276
+ if (part === '\n') {
277
+ return <br key={i} />;
278
+ }
279
+ return <span key={i}>{part}</span>;
280
+ });
281
+ }
components/chat/QuickChips.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { hapticFeedback } from '@/lib/mobile/touch';
4
+
5
+ interface QuickChipsProps {
6
+ topics: string[];
7
+ onSelect: (topic: string) => void;
8
+ }
9
+
10
+ export default function QuickChips({ topics, onSelect }: QuickChipsProps) {
11
+ return (
12
+ <div className="flex flex-wrap gap-2 px-4 py-3">
13
+ {topics.map((topic) => (
14
+ <button
15
+ key={topic}
16
+ onClick={() => {
17
+ hapticFeedback('light');
18
+ onSelect(`I have a question about ${topic.toLowerCase()}`);
19
+ }}
20
+ className="px-3.5 py-2 rounded-full bg-slate-800 border border-slate-700/50
21
+ text-sm text-slate-300 hover:bg-slate-700 hover:text-slate-100
22
+ hover:border-medical-primary/30 transition-all duration-200
23
+ touch-target whitespace-nowrap active:scale-95"
24
+ >
25
+ {topic}
26
+ </button>
27
+ ))}
28
+ </div>
29
+ );
30
+ }
components/chat/RightPanel.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { Phone, ExternalLink, Heart } from 'lucide-react';
5
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
6
+ import { trackSession } from '@/lib/analytics/anonymous-tracker';
7
+ import type { ViewType } from '../MedOSGlobalApp';
8
+ import type { SupportedLanguage } from '@/lib/i18n';
9
+
10
+ interface RightPanelProps {
11
+ countryCode: string;
12
+ language: SupportedLanguage;
13
+ onNavigate: (view: ViewType) => void;
14
+ }
15
+
16
+ const REGION_TOPICS: Record<string, string[]> = {
17
+ US: ['Diabetes', 'Mental Health', 'Heart Disease', 'Obesity', 'Cancer Screening'],
18
+ GB: ['Cancer', 'Mental Health', 'Heart Disease', 'Dementia', 'Asthma'],
19
+ IN: ['Tuberculosis', 'Diabetes', 'Dengue', 'Malaria', 'Maternal Health'],
20
+ NG: ['Malaria', 'HIV/AIDS', 'Tuberculosis', 'Maternal Health', 'Sickle Cell'],
21
+ BR: ['Dengue', 'Diabetes', 'Hypertension', 'Mental Health', 'Zika'],
22
+ CN: ['Stroke', 'Diabetes', 'Lung Cancer', 'Hypertension', 'Air Pollution'],
23
+ JP: ['Cancer', 'Dementia', 'Stroke', 'Mental Health', 'Allergies'],
24
+ SA: ['Diabetes', 'Obesity', 'Heart Disease', 'Heat Illness', 'Genetic Disorders'],
25
+ KE: ['Malaria', 'HIV/AIDS', 'TB', 'Maternal Health', 'Malnutrition'],
26
+ PH: ['Dengue', 'TB', 'Diabetes', 'Typhoid', 'Mental Health'],
27
+ DE: ['Cancer', 'Heart Disease', 'Mental Health', 'Allergies', 'Back Pain'],
28
+ EG: ['Diabetes', 'Hepatitis C', 'Heart Disease', 'Kidney Disease', 'Hypertension'],
29
+ };
30
+
31
+ export default function RightPanel({
32
+ countryCode,
33
+ onNavigate,
34
+ }: RightPanelProps) {
35
+ const emergencyInfo = getEmergencyInfo(countryCode);
36
+ const topics = REGION_TOPICS[countryCode] || REGION_TOPICS.US;
37
+ const [helpedCount, setHelpedCount] = useState(0);
38
+
39
+ useEffect(() => {
40
+ trackSession().then(setHelpedCount);
41
+ }, []);
42
+
43
+ return (
44
+ <div className="flex flex-col h-full bg-slate-900 p-4 overflow-y-auto scroll-smooth">
45
+ {/* Region Topics */}
46
+ <div className="mb-6">
47
+ <h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
48
+ Top Health Topics
49
+ </h3>
50
+ <div className="space-y-1">
51
+ {topics.map((topic) => (
52
+ <button
53
+ key={topic}
54
+ onClick={() => onNavigate('topics')}
55
+ className="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-300
56
+ hover:bg-slate-800 hover:text-slate-100 transition-colors"
57
+ >
58
+ {topic}
59
+ </button>
60
+ ))}
61
+ </div>
62
+ </div>
63
+
64
+ {/* Emergency Card */}
65
+ <div className="mb-6 p-4 rounded-xl bg-red-950/30 border border-red-800/30">
66
+ <div className="flex items-center gap-2 mb-2">
67
+ <Phone size={16} className="text-red-400" />
68
+ <h3 className="text-sm font-semibold text-red-300">Emergency</h3>
69
+ </div>
70
+ <p className="text-xs text-slate-400 mb-3">{emergencyInfo.country}</p>
71
+ <a
72
+ href={`tel:${emergencyInfo.emergency}`}
73
+ className="flex items-center justify-center gap-2 w-full py-2.5 rounded-lg
74
+ bg-red-600 hover:bg-red-500 text-white text-sm font-semibold
75
+ transition-colors touch-target emergency-pulse"
76
+ >
77
+ <Phone size={16} />
78
+ Call {emergencyInfo.emergency}
79
+ </a>
80
+ {emergencyInfo.crisisHotline && (
81
+ <p className="text-xs text-slate-400 mt-2 text-center">
82
+ Crisis: {emergencyInfo.crisisHotline}
83
+ </p>
84
+ )}
85
+ </div>
86
+
87
+ {/* Share Card */}
88
+ <div className="mb-6 p-4 rounded-xl bg-slate-800/50 border border-slate-700/30">
89
+ <h3 className="text-sm font-semibold text-slate-300 mb-2">
90
+ Share MedOS
91
+ </h3>
92
+ <p className="text-xs text-slate-400 mb-3">
93
+ Help others get free medical guidance
94
+ </p>
95
+ <button
96
+ onClick={() => onNavigate('share')}
97
+ className="flex items-center justify-center gap-2 w-full py-2 rounded-lg
98
+ bg-medical-primary hover:bg-blue-500 text-white text-sm
99
+ transition-colors touch-target"
100
+ >
101
+ <ExternalLink size={14} />
102
+ Share & Embed
103
+ </button>
104
+ </div>
105
+
106
+ {/* Social Proof */}
107
+ <div className="p-4 rounded-xl bg-slate-800/30 border border-slate-700/20">
108
+ <div className="flex items-center gap-2 mb-1">
109
+ <Heart size={14} className="text-medical-secondary" />
110
+ <span className="text-sm font-semibold text-slate-300">
111
+ {helpedCount.toLocaleString()}
112
+ </span>
113
+ </div>
114
+ <p className="text-xs text-slate-500">people helped worldwide</p>
115
+ </div>
116
+
117
+ {/* Open Source Badge */}
118
+ <div className="mt-auto pt-4 border-t border-slate-700/30">
119
+ <a
120
+ href="https://github.com/ruslanmv/ai-medical-chatbot"
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-400 transition-colors"
124
+ >
125
+ <ExternalLink size={12} />
126
+ Open Source on GitHub
127
+ </a>
128
+ <p className="text-xs text-slate-600 mt-1">
129
+ Powered by OllaBridge-Cloud
130
+ </p>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
components/chat/Sidebar.tsx ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import {
5
+ MessageSquare, BookOpen, AlertTriangle, Share2,
6
+ Settings, Globe, HelpCircle, Info, Smartphone,
7
+ ExternalLink, Github, ChevronUp, ChevronRight,
8
+ } from 'lucide-react';
9
+ import type { ViewType } from '../MedOSGlobalApp';
10
+ import type { SupportedLanguage } from '@/lib/i18n';
11
+ import { LANGUAGE_META } from '@/lib/i18n';
12
+
13
+ interface SidebarProps {
14
+ currentView: ViewType;
15
+ onNavigate: (view: ViewType) => void;
16
+ language: SupportedLanguage;
17
+ }
18
+
19
+ const NAV_ITEMS: Array<{ id: ViewType; icon: typeof MessageSquare; label: string }> = [
20
+ { id: 'chat', icon: MessageSquare, label: 'Chat' },
21
+ { id: 'topics', icon: BookOpen, label: 'Health Topics' },
22
+ { id: 'emergency', icon: AlertTriangle, label: 'Emergency SOS' },
23
+ { id: 'share', icon: Share2, label: 'Share & Embed' },
24
+ ];
25
+
26
+ export default function Sidebar({ currentView, onNavigate, language }: SidebarProps) {
27
+ const [showMenu, setShowMenu] = useState(false);
28
+ const menuRef = useRef<HTMLDivElement>(null);
29
+
30
+ // Close menu when clicking outside
31
+ useEffect(() => {
32
+ function handleClickOutside(event: MouseEvent) {
33
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
34
+ setShowMenu(false);
35
+ }
36
+ }
37
+ if (showMenu) {
38
+ document.addEventListener('mousedown', handleClickOutside);
39
+ return () => document.removeEventListener('mousedown', handleClickOutside);
40
+ }
41
+ }, [showMenu]);
42
+
43
+ // Close menu on Escape
44
+ useEffect(() => {
45
+ function handleEscape(event: KeyboardEvent) {
46
+ if (event.key === 'Escape') setShowMenu(false);
47
+ }
48
+ if (showMenu) {
49
+ document.addEventListener('keydown', handleEscape);
50
+ return () => document.removeEventListener('keydown', handleEscape);
51
+ }
52
+ }, [showMenu]);
53
+
54
+ const currentLangMeta = LANGUAGE_META[language];
55
+
56
+ return (
57
+ <div className="flex flex-col h-full bg-slate-100 dark:bg-slate-900 p-4 transition-colors">
58
+ {/* Logo */}
59
+ <div className="mb-6">
60
+ <h1 className="text-xl font-bold text-slate-800 dark:text-slate-50">
61
+ <span className="text-medical-primary">Med</span>OS
62
+ </h1>
63
+ <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Free AI Medical Assistant</p>
64
+ </div>
65
+
66
+ {/* Navigation */}
67
+ <nav className="flex-1 space-y-1">
68
+ {NAV_ITEMS.map((item) => {
69
+ const Icon = item.icon;
70
+ const isActive = currentView === item.id;
71
+ const isEmergency = item.id === 'emergency';
72
+
73
+ return (
74
+ <button
75
+ key={item.id}
76
+ onClick={() => onNavigate(item.id)}
77
+ className={`
78
+ w-full flex items-center gap-3 px-3 py-2.5 rounded-lg
79
+ touch-target transition-all duration-200
80
+ ${isActive
81
+ ? 'bg-blue-50 text-blue-700 dark:bg-slate-700/60 dark:text-slate-50'
82
+ : 'text-slate-500 hover:bg-slate-200 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200'
83
+ }
84
+ ${isEmergency && !isActive ? 'text-red-400 hover:text-red-300' : ''}
85
+ `}
86
+ >
87
+ <Icon
88
+ size={18}
89
+ className={isEmergency && !isActive ? 'text-red-400' : ''}
90
+ />
91
+ <span className="text-sm font-medium">{item.label}</span>
92
+ </button>
93
+ );
94
+ })}
95
+ </nav>
96
+
97
+ {/* User Menu Area */}
98
+ <div className="relative" ref={menuRef}>
99
+ {/* ===== Popup Menu (Claude-style) ===== */}
100
+ {showMenu && (
101
+ <div className="absolute bottom-full left-0 right-0 mb-2 z-50">
102
+ <div className="bg-slate-800 border border-slate-700/60 rounded-2xl shadow-2xl
103
+ overflow-hidden animate-slide-up w-full min-w-[240px]">
104
+
105
+ {/* Group 1: Core actions */}
106
+ <div className="p-1.5">
107
+ <MenuButton
108
+ icon={<Settings size={16} />}
109
+ label="Settings"
110
+ shortcut="Ctrl+,"
111
+ onClick={() => { onNavigate('settings'); setShowMenu(false); }}
112
+ />
113
+ <MenuButton
114
+ icon={<Globe size={16} />}
115
+ label="Language"
116
+ trailing={
117
+ <span className="flex items-center gap-1 text-xs text-slate-500">
118
+ {currentLangMeta?.flag} {currentLangMeta?.nativeName}
119
+ <ChevronRight size={12} />
120
+ </span>
121
+ }
122
+ onClick={() => { onNavigate('language'); setShowMenu(false); }}
123
+ />
124
+ <MenuButton
125
+ icon={<HelpCircle size={16} />}
126
+ label="Get help"
127
+ onClick={() => { onNavigate('about'); setShowMenu(false); }}
128
+ />
129
+ </div>
130
+
131
+ {/* Divider */}
132
+ <div className="h-px bg-slate-700/50 mx-2" />
133
+
134
+ {/* Group 2: Product */}
135
+ <div className="p-1.5">
136
+ <MenuButton
137
+ icon={<Smartphone size={16} />}
138
+ label="Install as App"
139
+ onClick={() => {
140
+ setShowMenu(false);
141
+ // Trigger PWA install
142
+ window.dispatchEvent(new CustomEvent('pwa-trigger-install'));
143
+ }}
144
+ />
145
+ <MenuButton
146
+ icon={<Share2 size={16} />}
147
+ label="Share MedOS"
148
+ onClick={() => { onNavigate('share'); setShowMenu(false); }}
149
+ />
150
+ <MenuButton
151
+ icon={<Info size={16} />}
152
+ label="About MedOS"
153
+ trailing={<ChevronRight size={12} className="text-slate-600" />}
154
+ onClick={() => { onNavigate('about'); setShowMenu(false); }}
155
+ />
156
+ </div>
157
+
158
+ {/* Divider */}
159
+ <div className="h-px bg-slate-700/50 mx-2" />
160
+
161
+ {/* Group 3: Links */}
162
+ <div className="p-1.5">
163
+ <MenuLink
164
+ icon={<Github size={16} />}
165
+ label="Source Code"
166
+ href="https://github.com/ruslanmv/ai-medical-chatbot"
167
+ />
168
+ <MenuLink
169
+ icon={<ExternalLink size={16} />}
170
+ label="OllaBridge Cloud"
171
+ href="https://github.com/ruslanmv/ollabridge"
172
+ />
173
+ </div>
174
+
175
+ {/* Footer */}
176
+ <div className="bg-slate-850 border-t border-slate-700/40 px-4 py-2.5">
177
+ <p className="text-[10px] text-slate-500">
178
+ MedOS v1.0 &middot; Powered by OllaBridge
179
+ </p>
180
+ <p className="text-[10px] text-slate-600">
181
+ Free &amp; Open Source &middot; Zero data retention
182
+ </p>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ )}
187
+
188
+ {/* ===== User Button (bottom of sidebar) ===== */}
189
+ <div className="pt-3 border-t border-slate-700/50">
190
+ <button
191
+ onClick={() => setShowMenu(!showMenu)}
192
+ className="w-full flex items-center gap-2.5 p-2 rounded-xl
193
+ hover:bg-slate-800 transition-colors group"
194
+ >
195
+ {/* Avatar */}
196
+ <div className="w-9 h-9 rounded-full bg-gradient-to-br from-medical-primary to-medical-secondary
197
+ flex items-center justify-center flex-shrink-0 ring-2 ring-slate-700/50">
198
+ <span className="text-sm font-bold text-white">G</span>
199
+ </div>
200
+
201
+ {/* Name & plan */}
202
+ <div className="flex-1 text-left min-w-0">
203
+ <p className="text-sm font-medium text-slate-200 truncate">Guest User</p>
204
+ <p className="text-[11px] text-slate-500">Free plan</p>
205
+ </div>
206
+
207
+ {/* Chevron */}
208
+ <ChevronUp
209
+ size={16}
210
+ className={`text-slate-500 transition-transform duration-200
211
+ ${showMenu ? '' : 'rotate-180'}`}
212
+ />
213
+ </button>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ /* ===== Reusable Menu Button ===== */
221
+ function MenuButton({
222
+ icon,
223
+ label,
224
+ shortcut,
225
+ trailing,
226
+ onClick,
227
+ }: {
228
+ icon: React.ReactNode;
229
+ label: string;
230
+ shortcut?: string;
231
+ trailing?: React.ReactNode;
232
+ onClick: () => void;
233
+ }) {
234
+ return (
235
+ <button
236
+ onClick={onClick}
237
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-slate-300
238
+ hover:bg-slate-700/60 transition-colors text-left group"
239
+ >
240
+ <span className="text-slate-400 group-hover:text-slate-300 transition-colors">
241
+ {icon}
242
+ </span>
243
+ <span className="flex-1 text-sm">{label}</span>
244
+ {shortcut && (
245
+ <kbd className="text-[10px] text-slate-600 bg-slate-750 px-1.5 py-0.5 rounded
246
+ border border-slate-700/50 font-mono">{shortcut}</kbd>
247
+ )}
248
+ {trailing}
249
+ </button>
250
+ );
251
+ }
252
+
253
+ /* ===== Reusable Menu Link (opens in new tab) ===== */
254
+ function MenuLink({
255
+ icon,
256
+ label,
257
+ href,
258
+ }: {
259
+ icon: React.ReactNode;
260
+ label: string;
261
+ href: string;
262
+ }) {
263
+ return (
264
+ <a
265
+ href={href}
266
+ target="_blank"
267
+ rel="noopener noreferrer"
268
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-slate-300
269
+ hover:bg-slate-700/60 transition-colors group"
270
+ >
271
+ <span className="text-slate-400 group-hover:text-slate-300 transition-colors">
272
+ {icon}
273
+ </span>
274
+ <span className="flex-1 text-sm">{label}</span>
275
+ <ExternalLink size={12} className="text-slate-600" />
276
+ </a>
277
+ );
278
+ }
components/chat/TrustBar.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { ShieldCheck, Globe2, Clock4 } from 'lucide-react';
4
+
5
+ /**
6
+ * Compact trust strip shown below the hero headline on the empty chat
7
+ * state and on symptom landing pages. Three icons, three short phrases,
8
+ * one color: muted teal. Communicates clinical credibility without
9
+ * looking salesy.
10
+ */
11
+ export default function TrustBar() {
12
+ const items = [
13
+ { Icon: ShieldCheck, text: 'Aligned with WHO · CDC · NHS' },
14
+ { Icon: Globe2, text: 'Private & anonymous' },
15
+ { Icon: Clock4, text: 'Available 24/7' },
16
+ ];
17
+ return (
18
+ <div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs text-slate-400">
19
+ {items.map(({ Icon, text }) => (
20
+ <span key={text} className="inline-flex items-center gap-1.5 font-medium">
21
+ <Icon size={13} className="text-teal-400" />
22
+ {text}
23
+ </span>
24
+ ))}
25
+ </div>
26
+ );
27
+ }
components/chat/TypingIndicator.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ export default function TypingIndicator() {
4
+ return (
5
+ <div className="flex justify-start animate-fade-in">
6
+ <div className="bg-slate-800 border border-slate-700/50 rounded-2xl px-4 py-3">
7
+ <div className="flex items-center gap-1.5">
8
+ <div className="typing-dot" />
9
+ <div className="typing-dot" />
10
+ <div className="typing-dot" />
11
+ </div>
12
+ </div>
13
+ </div>
14
+ );
15
+ }
components/chat/VoiceInput.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
+ import { Mic, MicOff } from 'lucide-react';
5
+ import { hapticFeedback } from '@/lib/mobile/touch';
6
+
7
+ interface VoiceInputProps {
8
+ onTranscript: (text: string) => void;
9
+ language: string;
10
+ }
11
+
12
+ function getSpeechRecognitionClass(): SpeechRecognitionConstructor | null {
13
+ if (typeof window === 'undefined') return null;
14
+ return window.SpeechRecognition || window.webkitSpeechRecognition || null;
15
+ }
16
+
17
+ export default function VoiceInput({ onTranscript, language }: VoiceInputProps) {
18
+ const [isListening, setIsListening] = useState(false);
19
+ const [isSupported, setIsSupported] = useState(false);
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ const recognitionRef = useRef<any>(null);
22
+
23
+ // Check support only on client after mount
24
+ useEffect(() => {
25
+ setIsSupported(!!getSpeechRecognitionClass());
26
+ }, []);
27
+
28
+ const toggleListening = useCallback(() => {
29
+ const SpeechRecognitionCtor = getSpeechRecognitionClass();
30
+ if (!SpeechRecognitionCtor) return;
31
+
32
+ if (isListening && recognitionRef.current) {
33
+ recognitionRef.current.stop();
34
+ setIsListening(false);
35
+ return;
36
+ }
37
+
38
+ hapticFeedback('medium');
39
+
40
+ const recognition = new SpeechRecognitionCtor();
41
+ recognition.continuous = false;
42
+ recognition.interimResults = false;
43
+ recognition.lang = getRecognitionLang(language);
44
+
45
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
46
+ const transcript = event.results[0][0].transcript;
47
+ if (transcript.trim()) {
48
+ onTranscript(transcript.trim());
49
+ }
50
+ };
51
+
52
+ recognition.onend = () => setIsListening(false);
53
+ recognition.onerror = () => setIsListening(false);
54
+
55
+ recognitionRef.current = recognition;
56
+ recognition.start();
57
+ setIsListening(true);
58
+ }, [isListening, language, onTranscript]);
59
+
60
+ // Render nothing until we know if supported (avoids hydration mismatch)
61
+ if (!isSupported) return null;
62
+
63
+ return (
64
+ <button
65
+ onClick={toggleListening}
66
+ className={`
67
+ touch-target rounded-full p-2.5 transition-all duration-200
68
+ ${isListening
69
+ ? 'bg-red-500 text-white voice-recording'
70
+ : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
71
+ }
72
+ `}
73
+ aria-label={isListening ? 'Stop recording' : 'Start voice input'}
74
+ >
75
+ {isListening ? <MicOff size={20} /> : <Mic size={20} />}
76
+ </button>
77
+ );
78
+ }
79
+
80
+ function getRecognitionLang(lang: string): string {
81
+ const langMap: Record<string, string> = {
82
+ en: 'en-US', es: 'es-ES', zh: 'zh-CN', hi: 'hi-IN', ar: 'ar-SA',
83
+ pt: 'pt-BR', bn: 'bn-BD', fr: 'fr-FR', ru: 'ru-RU', ja: 'ja-JP',
84
+ de: 'de-DE', ko: 'ko-KR', tr: 'tr-TR', vi: 'vi-VN', it: 'it-IT',
85
+ th: 'th-TH', id: 'id-ID', sw: 'sw-KE', tl: 'tl-PH', uk: 'uk-UA',
86
+ };
87
+ return langMap[lang] || 'en-US';
88
+ }
components/mobile/BottomNav.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { MessageSquare, BookOpen, AlertTriangle, Settings, Share2 } from 'lucide-react';
4
+ import type { ViewType } from '../MedOSGlobalApp';
5
+ import type { SupportedLanguage } from '@/lib/i18n';
6
+ import { hapticFeedback } from '@/lib/mobile/touch';
7
+
8
+ interface BottomNavProps {
9
+ currentView: ViewType;
10
+ onNavigate: (view: ViewType) => void;
11
+ language: SupportedLanguage;
12
+ }
13
+
14
+ const NAV_ITEMS: Array<{
15
+ id: ViewType;
16
+ icon: typeof MessageSquare;
17
+ label: string;
18
+ }> = [
19
+ { id: 'chat', icon: MessageSquare, label: 'Chat' },
20
+ { id: 'topics', icon: BookOpen, label: 'Topics' },
21
+ { id: 'emergency', icon: AlertTriangle, label: 'SOS' },
22
+ { id: 'share', icon: Share2, label: 'Share' },
23
+ { id: 'settings', icon: Settings, label: 'More' },
24
+ ];
25
+
26
+ export default function BottomNav({
27
+ currentView,
28
+ onNavigate,
29
+ }: BottomNavProps) {
30
+ return (
31
+ <nav className="bottom-nav flex items-center justify-around border-t border-slate-700/50 px-2 py-1">
32
+ {NAV_ITEMS.map((item) => {
33
+ const Icon = item.icon;
34
+ const isActive = currentView === item.id;
35
+ const isEmergency = item.id === 'emergency';
36
+
37
+ return (
38
+ <button
39
+ key={item.id}
40
+ onClick={() => {
41
+ hapticFeedback('light');
42
+ onNavigate(item.id);
43
+ }}
44
+ className={`
45
+ flex flex-col items-center justify-center gap-0.5
46
+ touch-target px-3 py-1.5 rounded-lg transition-all duration-200
47
+ ${isActive
48
+ ? 'text-medical-primary'
49
+ : isEmergency
50
+ ? 'text-red-400'
51
+ : 'text-slate-500'
52
+ }
53
+ `}
54
+ >
55
+ <Icon
56
+ size={20}
57
+ className={isActive ? 'text-medical-primary' : ''}
58
+ />
59
+ <span className="text-[10px] font-medium">{item.label}</span>
60
+ </button>
61
+ );
62
+ })}
63
+ </nav>
64
+ );
65
+ }
components/mobile/InstallPrompt.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { Download, X } from 'lucide-react';
5
+ import { canInstallPWA, installPWA, isPWAInstalled } from '@/lib/mobile/pwa';
6
+
7
+ export default function InstallPrompt() {
8
+ const [showPrompt, setShowPrompt] = useState(false);
9
+
10
+ useEffect(() => {
11
+ if (isPWAInstalled()) return;
12
+
13
+ const handleInstallable = () => setShowPrompt(true);
14
+ window.addEventListener('pwa-installable', handleInstallable);
15
+
16
+ // Check if already installable
17
+ if (canInstallPWA()) setShowPrompt(true);
18
+
19
+ return () => {
20
+ window.removeEventListener('pwa-installable', handleInstallable);
21
+ };
22
+ }, []);
23
+
24
+ if (!showPrompt) return null;
25
+
26
+ return (
27
+ <div className="fixed bottom-20 left-4 right-4 z-50 animate-slide-up md:left-auto md:right-4 md:max-w-sm">
28
+ <div className="bg-slate-800 border border-slate-700/50 rounded-2xl p-4 shadow-xl">
29
+ <div className="flex items-start gap-3">
30
+ <div className="w-10 h-10 rounded-xl bg-medical-primary/20 flex items-center justify-center flex-shrink-0">
31
+ <Download size={20} className="text-medical-primary" />
32
+ </div>
33
+ <div className="flex-1 min-w-0">
34
+ <h3 className="text-sm font-semibold text-slate-200">
35
+ Install MedOS
36
+ </h3>
37
+ <p className="text-xs text-slate-400 mt-1">
38
+ Add to your home screen for quick access, offline support, and a
39
+ native app experience.
40
+ </p>
41
+ </div>
42
+ <button
43
+ onClick={() => setShowPrompt(false)}
44
+ className="touch-target p-1 text-slate-500 hover:text-slate-300"
45
+ >
46
+ <X size={16} />
47
+ </button>
48
+ </div>
49
+ <div className="flex gap-2 mt-3">
50
+ <button
51
+ onClick={() => setShowPrompt(false)}
52
+ className="flex-1 py-2 rounded-lg text-sm text-slate-400
53
+ hover:text-slate-300 transition-colors"
54
+ >
55
+ Not now
56
+ </button>
57
+ <button
58
+ onClick={async () => {
59
+ await installPWA();
60
+ setShowPrompt(false);
61
+ }}
62
+ className="flex-1 py-2 rounded-lg bg-medical-primary text-white text-sm
63
+ font-semibold hover:bg-blue-500 transition-colors"
64
+ >
65
+ Install
66
+ </button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ );
71
+ }
components/ui/DisclaimerBanner.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { AlertTriangle } from 'lucide-react';
4
+ import { getDisclaimer } from '@/lib/safety/disclaimer';
5
+ import type { SupportedLanguage } from '@/lib/i18n';
6
+
7
+ interface DisclaimerBannerProps {
8
+ language: SupportedLanguage;
9
+ }
10
+
11
+ export default function DisclaimerBanner({ language }: DisclaimerBannerProps) {
12
+ const disclaimer = getDisclaimer(language);
13
+
14
+ return (
15
+ <div className="flex-shrink-0 px-4 py-2 bg-slate-800/50 border-t border-slate-700/30">
16
+ <div className="flex items-start gap-2">
17
+ <AlertTriangle
18
+ size={12}
19
+ className="text-amber-500/70 mt-0.5 flex-shrink-0"
20
+ />
21
+ <p className="text-[10px] text-slate-500 leading-relaxed">
22
+ {disclaimer}
23
+ </p>
24
+ </div>
25
+ </div>
26
+ );
27
+ }
components/ui/InstallPrompt.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Download, X } from 'lucide-react';
5
+
6
+ /**
7
+ * Custom PWA install prompt banner.
8
+ *
9
+ * - Captures the standard `beforeinstallprompt` event (Chromium/Edge/
10
+ * Samsung) and defers it so we can surface a friendlier custom banner.
11
+ * - Shows AFTER a short delay so it doesn't interrupt the very first
12
+ * interaction — users install apps they already like.
13
+ * - Dismissal is persisted in localStorage so we never nag.
14
+ * - Re-appears automatically if the user uninstalls and comes back (the
15
+ * browser will fire `beforeinstallprompt` again).
16
+ * - Gracefully absent on iOS Safari (which has no install event) — iOS
17
+ * users get an Add-to-Home-Screen hint via the manifest anyway.
18
+ */
19
+
20
+ interface BeforeInstallPromptEvent extends Event {
21
+ prompt: () => Promise<void>;
22
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
23
+ }
24
+
25
+ const DISMISS_KEY = 'medos_install_dismissed';
26
+ const DELAY_MS = 8000;
27
+
28
+ export default function InstallPrompt() {
29
+ const [evt, setEvt] = useState<BeforeInstallPromptEvent | null>(null);
30
+ const [visible, setVisible] = useState(false);
31
+
32
+ useEffect(() => {
33
+ if (typeof window === 'undefined') return;
34
+ if (localStorage.getItem(DISMISS_KEY) === '1') return;
35
+
36
+ // Already installed? Nothing to do.
37
+ if (window.matchMedia?.('(display-mode: standalone)').matches) return;
38
+
39
+ const handler = (e: Event) => {
40
+ e.preventDefault();
41
+ setEvt(e as BeforeInstallPromptEvent);
42
+ // Delay the reveal so it never interrupts the first answer.
43
+ setTimeout(() => setVisible(true), DELAY_MS);
44
+ };
45
+ window.addEventListener('beforeinstallprompt', handler as EventListener);
46
+ return () => {
47
+ window.removeEventListener('beforeinstallprompt', handler as EventListener);
48
+ };
49
+ }, []);
50
+
51
+ const install = async () => {
52
+ if (!evt) return;
53
+ try {
54
+ await evt.prompt();
55
+ const choice = await evt.userChoice;
56
+ if (choice.outcome === 'accepted') {
57
+ localStorage.setItem(DISMISS_KEY, '1');
58
+ }
59
+ } catch {
60
+ // User cancelled or unsupported — just hide the banner.
61
+ } finally {
62
+ setVisible(false);
63
+ setEvt(null);
64
+ }
65
+ };
66
+
67
+ const dismiss = () => {
68
+ localStorage.setItem(DISMISS_KEY, '1');
69
+ setVisible(false);
70
+ setEvt(null);
71
+ };
72
+
73
+ if (!visible || !evt) return null;
74
+
75
+ return (
76
+ <div
77
+ role="dialog"
78
+ aria-label="Install MedOS on your device"
79
+ className="fixed left-1/2 -translate-x-1/2 bottom-4 z-50 w-[calc(100%-2rem)] max-w-sm
80
+ rounded-2xl border border-slate-700/60 bg-slate-900/95 backdrop-blur-xl
81
+ shadow-2xl shadow-blue-500/10 animate-fade-in"
82
+ >
83
+ <div className="flex items-start gap-3 p-4">
84
+ <div className="flex-shrink-0 w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 flex items-center justify-center shadow-lg shadow-blue-500/30">
85
+ <Download size={18} className="text-white" />
86
+ </div>
87
+ <div className="flex-1 min-w-0">
88
+ <p className="text-sm font-bold text-slate-100">
89
+ Install MedOS on your phone
90
+ </p>
91
+ <p className="text-xs text-slate-400 mt-0.5 leading-relaxed">
92
+ Add to your home screen for instant access. Free, private, offline-ready.
93
+ </p>
94
+ <div className="flex items-center gap-2 mt-3">
95
+ <button
96
+ type="button"
97
+ onClick={install}
98
+ className="px-3 py-1.5 rounded-lg bg-gradient-to-br from-blue-500 to-teal-500 text-white text-xs font-bold hover:brightness-110 transition-all"
99
+ >
100
+ Install
101
+ </button>
102
+ <button
103
+ type="button"
104
+ onClick={dismiss}
105
+ className="px-3 py-1.5 rounded-lg text-slate-400 hover:text-slate-200 text-xs font-semibold transition-colors"
106
+ >
107
+ Not now
108
+ </button>
109
+ </div>
110
+ </div>
111
+ <button
112
+ type="button"
113
+ onClick={dismiss}
114
+ aria-label="Dismiss"
115
+ className="flex-shrink-0 text-slate-500 hover:text-slate-300 transition-colors"
116
+ >
117
+ <X size={16} />
118
+ </button>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
components/ui/OfflineBanner.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { WifiOff } from 'lucide-react';
4
+ import type { SupportedLanguage } from '@/lib/i18n';
5
+
6
+ interface OfflineBannerProps {
7
+ language: SupportedLanguage;
8
+ }
9
+
10
+ const OFFLINE_MESSAGES: Record<string, string> = {
11
+ en: "You're offline. Showing cached answers.",
12
+ es: 'Estás sin conexión. Mostrando respuestas guardadas.',
13
+ zh: '您已离线。正在显示缓存的回答。',
14
+ hi: 'आप ऑफलाइन हैं। सहेजे गए उत्तर दिखा रहे हैं।',
15
+ ar: 'أنت غير متصل. عرض الإجابات المحفوظة.',
16
+ pt: 'Você está offline. Mostrando respostas salvas.',
17
+ fr: 'Vous êtes hors ligne. Affichage des réponses en cache.',
18
+ ru: 'Вы офлайн. Показаны сохранённые ответы.',
19
+ ja: 'オフラインです。保存された回答を表示しています。',
20
+ de: 'Sie sind offline. Gespeicherte Antworten werden angezeigt.',
21
+ };
22
+
23
+ export default function OfflineBanner({ language }: OfflineBannerProps) {
24
+ const message =
25
+ OFFLINE_MESSAGES[language] || OFFLINE_MESSAGES.en;
26
+
27
+ return (
28
+ <div className="flex items-center justify-center gap-2 px-4 py-2 bg-amber-900/30 border-b border-amber-700/30">
29
+ <WifiOff size={14} className="text-amber-400" />
30
+ <p className="text-xs text-amber-300">{message}</p>
31
+ </div>
32
+ );
33
+ }
components/ui/ShareButtons.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+
5
+ interface ShareButtonsProps {
6
+ url: string;
7
+ language: string;
8
+ }
9
+
10
+ const SHARE_MESSAGE =
11
+ 'I just got free AI medical advice! Try MediBot - free, works in 20 languages, no sign-up needed:';
12
+
13
+ export default function ShareButtons({ url }: ShareButtonsProps) {
14
+ const [canShare, setCanShare] = useState(false);
15
+
16
+ useEffect(() => {
17
+ setCanShare(typeof navigator !== 'undefined' && 'share' in navigator);
18
+ }, []);
19
+
20
+ const encodedUrl = encodeURIComponent(url);
21
+ const encodedMessage = encodeURIComponent(`${SHARE_MESSAGE} ${url}`);
22
+
23
+ const platforms = [
24
+ { name: 'WhatsApp', href: `https://wa.me/?text=${encodedMessage}`, color: 'bg-green-600 hover:bg-green-500', label: 'WA' },
25
+ { name: 'Telegram', href: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(SHARE_MESSAGE)}`, color: 'bg-blue-500 hover:bg-blue-400', label: 'TG' },
26
+ { name: 'Twitter', href: `https://twitter.com/intent/tweet?text=${encodedMessage}`, color: 'bg-slate-700 hover:bg-slate-600', label: 'X' },
27
+ { name: 'Facebook', href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, color: 'bg-blue-700 hover:bg-blue-600', label: 'FB' },
28
+ { name: 'LinkedIn', href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, color: 'bg-blue-800 hover:bg-blue-700', label: 'LI' },
29
+ ];
30
+
31
+ const handleNativeShare = async () => {
32
+ if (typeof navigator !== 'undefined' && navigator.share) {
33
+ try {
34
+ await navigator.share({ title: 'MediBot - Free AI Medical Assistant', text: SHARE_MESSAGE, url });
35
+ } catch { /* user cancelled */ }
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div className="space-y-3">
41
+ {canShare && (
42
+ <button onClick={handleNativeShare}
43
+ className="w-full py-3 rounded-xl bg-medical-primary hover:bg-blue-500
44
+ text-white text-sm font-semibold transition-colors touch-target">
45
+ Share via...
46
+ </button>
47
+ )}
48
+ <div className="grid grid-cols-5 gap-2">
49
+ {platforms.map((p) => (
50
+ <a key={p.name} href={p.href} target="_blank" rel="noopener noreferrer"
51
+ className={`flex flex-col items-center justify-center gap-1 py-3 rounded-xl ${p.color} text-white transition-colors touch-target`}>
52
+ <span className="text-sm font-bold">{p.label}</span>
53
+ <span className="text-[9px] opacity-80">{p.name}</span>
54
+ </a>
55
+ ))}
56
+ </div>
57
+ </div>
58
+ );
59
+ }
components/ui/ThemeToggle.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Sun, Moon, Monitor } from 'lucide-react';
4
+ import type { ThemeMode } from '@/lib/hooks/useTheme';
5
+
6
+ interface ThemeToggleProps {
7
+ theme: ThemeMode;
8
+ setTheme: (t: ThemeMode) => void;
9
+ }
10
+
11
+ const OPTIONS: { id: ThemeMode; Icon: typeof Sun }[] = [
12
+ { id: 'light', Icon: Sun },
13
+ { id: 'dark', Icon: Moon },
14
+ { id: 'system', Icon: Monitor },
15
+ ];
16
+
17
+ export default function ThemeToggle({ theme, setTheme }: ThemeToggleProps) {
18
+ return (
19
+ <div
20
+ role="radiogroup"
21
+ aria-label="Theme"
22
+ className="inline-flex items-center gap-0.5 rounded-full p-0.5 bg-slate-200/70 dark:bg-slate-800/70 border border-slate-300/60 dark:border-slate-700/60"
23
+ >
24
+ {OPTIONS.map(({ id, Icon }) => {
25
+ const active = theme === id;
26
+ return (
27
+ <button
28
+ key={id}
29
+ role="radio"
30
+ aria-checked={active}
31
+ onClick={() => setTheme(id)}
32
+ className={`flex items-center justify-center h-7 w-7 rounded-full transition-all ${
33
+ active
34
+ ? 'bg-white dark:bg-slate-700 text-medical-primary shadow-sm'
35
+ : 'text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300'
36
+ }`}
37
+ >
38
+ <Icon size={14} strokeWidth={2.25} />
39
+ </button>
40
+ );
41
+ })}
42
+ </div>
43
+ );
44
+ }
components/views/AboutView.tsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { ExternalLink, Heart, Shield, Globe, Zap, Smartphone } from 'lucide-react';
5
+ import { fetchCount } from '@/lib/analytics/anonymous-tracker';
6
+
7
+ export default function AboutView() {
8
+ const [helpedCount, setHelpedCount] = useState(0);
9
+
10
+ useEffect(() => {
11
+ fetchCount().then(setHelpedCount);
12
+ }, []);
13
+
14
+ return (
15
+ <div className="flex-1 overflow-y-auto scroll-smooth">
16
+ {/* Header */}
17
+ <div className="px-4 py-6 text-center border-b border-slate-700/50">
18
+ <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-medical-primary to-medical-secondary flex items-center justify-center mx-auto mb-3">
19
+ <span className="text-3xl">&#x1F3E5;</span>
20
+ </div>
21
+ <h2 className="text-2xl font-bold text-slate-100">
22
+ <span className="text-medical-primary">Med</span>OS
23
+ </h2>
24
+ <p className="text-sm text-slate-400 mt-1">Free AI Medical Assistant</p>
25
+ <p className="text-xs text-slate-500 mt-1">Version 1.0.0</p>
26
+ </div>
27
+
28
+ <div className="p-4 space-y-6">
29
+ {/* Mission */}
30
+ <div className="text-center">
31
+ <p className="text-sm text-slate-300 leading-relaxed max-w-md mx-auto">
32
+ MedOS provides free, multilingual health information to everyone worldwide.
33
+ No sign-up. No cost. No data collected.
34
+ </p>
35
+ </div>
36
+
37
+ {/* Stats */}
38
+ <div className="grid grid-cols-3 gap-3">
39
+ <div className="text-center p-3 rounded-xl bg-slate-800/50 border border-slate-700/20">
40
+ <p className="text-lg font-bold text-medical-primary">{helpedCount > 0 ? helpedCount.toLocaleString() : '---'}</p>
41
+ <p className="text-[10px] text-slate-500">People Helped</p>
42
+ </div>
43
+ <div className="text-center p-3 rounded-xl bg-slate-800/50 border border-slate-700/20">
44
+ <p className="text-lg font-bold text-medical-secondary">20</p>
45
+ <p className="text-[10px] text-slate-500">Languages</p>
46
+ </div>
47
+ <div className="text-center p-3 rounded-xl bg-slate-800/50 border border-slate-700/20">
48
+ <p className="text-lg font-bold text-amber-400">190+</p>
49
+ <p className="text-[10px] text-slate-500">Countries</p>
50
+ </div>
51
+ </div>
52
+
53
+ {/* Features */}
54
+ <div>
55
+ <h3 className="text-sm font-semibold text-slate-300 mb-3">Features</h3>
56
+ <div className="space-y-2">
57
+ {[
58
+ { icon: Zap, title: 'Multi-Provider AI', desc: 'Routes to best free LLM via OllaBridge (Gemini, Groq, OpenRouter)' },
59
+ { icon: Globe, title: '20 Languages', desc: 'Auto-detects your language with RTL support' },
60
+ { icon: Shield, title: 'Emergency Triage', desc: 'Detects emergencies and shows local emergency numbers' },
61
+ { icon: Smartphone, title: 'Mobile PWA', desc: 'Install on your phone, works offline' },
62
+ { icon: Heart, title: 'Zero Data Retention', desc: 'No conversations stored. No PII collected. Ever.' },
63
+ ].map((feature) => {
64
+ const Icon = feature.icon;
65
+ return (
66
+ <div key={feature.title} className="flex items-start gap-3 p-3 rounded-xl bg-slate-800/30">
67
+ <Icon size={16} className="text-medical-primary mt-0.5 flex-shrink-0" />
68
+ <div>
69
+ <p className="text-sm font-medium text-slate-200">{feature.title}</p>
70
+ <p className="text-xs text-slate-500">{feature.desc}</p>
71
+ </div>
72
+ </div>
73
+ );
74
+ })}
75
+ </div>
76
+ </div>
77
+
78
+ {/* Powered By */}
79
+ <div>
80
+ <h3 className="text-sm font-semibold text-slate-300 mb-3">Powered By</h3>
81
+ <div className="space-y-2">
82
+ <a href="https://github.com/ruslanmv/ollabridge" target="_blank" rel="noopener noreferrer"
83
+ className="flex items-center gap-3 p-3 rounded-xl bg-slate-800/50 border border-slate-700/30 hover:bg-slate-800 transition-colors">
84
+ <div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
85
+ <Zap size={16} className="text-blue-400" />
86
+ </div>
87
+ <div className="flex-1">
88
+ <p className="text-sm font-medium text-slate-200">OllaBridge-Cloud</p>
89
+ <p className="text-xs text-slate-500">Multi-provider LLM gateway</p>
90
+ </div>
91
+ <ExternalLink size={14} className="text-slate-500" />
92
+ </a>
93
+ <a href="https://github.com/ruslanmv/ai-medical-chatbot" target="_blank" rel="noopener noreferrer"
94
+ className="flex items-center gap-3 p-3 rounded-xl bg-slate-800/50 border border-slate-700/30 hover:bg-slate-800 transition-colors">
95
+ <div className="w-8 h-8 rounded-lg bg-green-600/20 flex items-center justify-center">
96
+ <Heart size={16} className="text-green-400" />
97
+ </div>
98
+ <div className="flex-1">
99
+ <p className="text-sm font-medium text-slate-200">AI Medical Chatbot</p>
100
+ <p className="text-xs text-slate-500">Medical RAG knowledge base</p>
101
+ </div>
102
+ <ExternalLink size={14} className="text-slate-500" />
103
+ </a>
104
+ </div>
105
+ </div>
106
+
107
+ {/* License */}
108
+ <div className="text-center pt-4 border-t border-slate-700/30">
109
+ <p className="text-xs text-slate-500">Apache 2.0 License</p>
110
+ <p className="text-xs text-slate-600 mt-1">
111
+ This AI provides general health information only.
112
+ Always consult a healthcare professional.
113
+ </p>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ );
118
+ }
components/views/ChatView.tsx ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useRef, useEffect } from 'react';
4
+ import { Send, Sparkles } from 'lucide-react';
5
+ import type { ChatMessage } from '../MedOSGlobalApp';
6
+ import type { SupportedLanguage } from '@/lib/i18n';
7
+ import MessageBubble from '../chat/MessageBubble';
8
+ import TypingIndicator from '../chat/TypingIndicator';
9
+ import VoiceInput from '../chat/VoiceInput';
10
+ import QuickChips from '../chat/QuickChips';
11
+ import TrustBar from '../chat/TrustBar';
12
+ import { trackSession } from '@/lib/analytics/anonymous-tracker';
13
+ import { readPrefillQuery } from '@/lib/share';
14
+ import { useTheme } from '@/lib/hooks/useTheme';
15
+ import ThemeToggle from '../ui/ThemeToggle';
16
+ import { suggestFollowUps } from '@/lib/follow-ups';
17
+ import { HERO_VARIANTS, pickVariant } from '@/lib/ab-variant';
18
+
19
+ interface ChatViewProps {
20
+ messages: ChatMessage[];
21
+ isLoading: boolean;
22
+ language: SupportedLanguage;
23
+ onSendMessage: (message: string) => void;
24
+ }
25
+
26
+ const DEFAULT_TOPICS = [
27
+ 'Headache',
28
+ 'Fever',
29
+ 'Cough',
30
+ 'Diabetes',
31
+ 'Blood Pressure',
32
+ 'Pregnancy',
33
+ 'Mental Health',
34
+ 'Child Health',
35
+ ];
36
+
37
+ // Rotating empathetic placeholders — one of the single biggest virality
38
+ // levers for a medical chatbot. Users see an example of exactly how to
39
+ // phrase their concern.
40
+ const ROTATING_PLACEHOLDERS = [
41
+ 'I have chest pain since this morning…',
42
+ 'My child has a fever of 39 °C…',
43
+ 'I\'ve had a headache for three days…',
44
+ 'Is this medication safe with pregnancy?',
45
+ 'I feel anxious and can\'t sleep…',
46
+ ];
47
+
48
+ export default function ChatView({
49
+ messages,
50
+ isLoading,
51
+ language,
52
+ onSendMessage,
53
+ }: ChatViewProps) {
54
+ const { theme, setTheme } = useTheme();
55
+ const [input, setInput] = useState('');
56
+ const [rotIdx, setRotIdx] = useState(0);
57
+ // Deterministic A/B variant — picked once per visitor and persisted.
58
+ // Default to 0 for SSR; effect hydrates the real selection on mount.
59
+ const [variant, setVariant] = useState(0);
60
+ const messagesEndRef = useRef<HTMLDivElement>(null);
61
+ const inputRef = useRef<HTMLInputElement>(null);
62
+ const prefillHandled = useRef(false);
63
+
64
+ // Auto-scroll on new messages.
65
+ useEffect(() => {
66
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
67
+ }, [messages]);
68
+
69
+ // Rotate the placeholder every 3.2 s while the input is empty.
70
+ useEffect(() => {
71
+ if (input) return;
72
+ const id = setInterval(
73
+ () => setRotIdx((i) => (i + 1) % ROTATING_PLACEHOLDERS.length),
74
+ 3200,
75
+ );
76
+ return () => clearInterval(id);
77
+ }, [input]);
78
+
79
+ // Pick the A/B hero variant once on mount.
80
+ useEffect(() => {
81
+ setVariant(pickVariant(HERO_VARIANTS.length));
82
+ }, []);
83
+
84
+ // Viral entry point: `?q=` in the URL pre-fills the input and auto-sends
85
+ // once, so a shared link becomes an instant conversation starter.
86
+ useEffect(() => {
87
+ if (prefillHandled.current) return;
88
+ const q = readPrefillQuery();
89
+ if (q) {
90
+ prefillHandled.current = true;
91
+ const url = new URL(window.location.href);
92
+ url.searchParams.delete('q');
93
+ window.history.replaceState({}, '', url.toString());
94
+ trackSession();
95
+ onSendMessage(q);
96
+ }
97
+ }, [onSendMessage]);
98
+
99
+ const handleSend = () => {
100
+ if (!input.trim() || isLoading) return;
101
+ trackSession();
102
+ onSendMessage(input);
103
+ setInput('');
104
+ };
105
+
106
+ const handleKeyDown = (e: React.KeyboardEvent) => {
107
+ if (e.key === 'Enter' && !e.shiftKey) {
108
+ e.preventDefault();
109
+ handleSend();
110
+ }
111
+ };
112
+
113
+ const handleVoiceTranscript = (text: string) => {
114
+ trackSession();
115
+ onSendMessage(text);
116
+ };
117
+
118
+ const handleQuickTopic = (topic: string) => {
119
+ trackSession();
120
+ onSendMessage(topic);
121
+ };
122
+
123
+ // Look up the user question that triggered each assistant message, so
124
+ // the assistant bubble's Share button can build a clean deep link.
125
+ const sourceQuestionFor = (index: number): string | undefined => {
126
+ for (let i = index - 1; i >= 0; i--) {
127
+ if (messages[i].role === 'user') return messages[i].content;
128
+ }
129
+ return undefined;
130
+ };
131
+
132
+ // Show CONTEXTUAL follow-up chips derived from the latest AI answer.
133
+ // `suggestFollowUps` scans medical keywords and picks topical prompts,
134
+ // falling back to universal defaults when nothing matches.
135
+ const lastMessage = messages[messages.length - 1];
136
+ const showFollowUps =
137
+ !isLoading &&
138
+ messages.length > 0 &&
139
+ lastMessage?.role === 'assistant' &&
140
+ lastMessage.content.length > 40;
141
+ const followUps = useMemo(
142
+ () =>
143
+ showFollowUps && lastMessage
144
+ ? suggestFollowUps(lastMessage.content, 4)
145
+ : [],
146
+ [showFollowUps, lastMessage],
147
+ );
148
+
149
+ const hero = HERO_VARIANTS[variant] ?? HERO_VARIANTS[0];
150
+
151
+ return (
152
+ <div className="flex flex-col flex-1 min-h-0">
153
+ {/* Mobile Header */}
154
+ <div className="mobile-header-compact flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700/50">
155
+ <h1 className="text-lg font-bold text-slate-800 dark:text-slate-50">
156
+ <span className="text-medical-primary">Med</span>OS
157
+ </h1>
158
+ <div className="flex items-center gap-2">
159
+ <ThemeToggle theme={theme} setTheme={setTheme} />
160
+ <span className="text-xs text-slate-500">Free</span>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Messages Area */}
165
+ <div className="flex-1 overflow-y-auto scroll-smooth px-4 py-4 space-y-4">
166
+ {messages.length === 0 && (
167
+ <div className="flex flex-col items-center justify-center h-full text-center animate-fade-in">
168
+ <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-teal-500 flex items-center justify-center mb-4 shadow-lg shadow-blue-500/30">
169
+ <span className="text-3xl">&#x1F3E5;</span>
170
+ </div>
171
+ <p className="text-[11px] font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
172
+ {hero.eyebrow}
173
+ </p>
174
+ <h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2 tracking-tight max-w-md">
175
+ {hero.title}
176
+ </h2>
177
+ <p className="text-sm text-slate-500 dark:text-slate-400 mb-5 max-w-md leading-relaxed">
178
+ {hero.subtitle}
179
+ </p>
180
+ <div className="mb-6">
181
+ <TrustBar />
182
+ </div>
183
+ <QuickChips topics={DEFAULT_TOPICS} onSelect={handleQuickTopic} />
184
+ </div>
185
+ )}
186
+
187
+ {messages.map((msg, i) => (
188
+ <MessageBubble
189
+ key={msg.id}
190
+ message={msg}
191
+ sourceQuestion={msg.role === 'assistant' ? sourceQuestionFor(i) : undefined}
192
+ language={language}
193
+ />
194
+ ))}
195
+
196
+ {isLoading && messages[messages.length - 1]?.content === '' && (
197
+ <TypingIndicator />
198
+ )}
199
+
200
+ {/* Contextual follow-up suggestion chips */}
201
+ {showFollowUps && followUps.length > 0 && (
202
+ <div className="flex flex-wrap items-center gap-2 pl-1 pt-2 animate-fade-in">
203
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-teal-400">
204
+ <Sparkles size={11} />
205
+ Follow up
206
+ </span>
207
+ {followUps.map((f) => (
208
+ <button
209
+ key={f.id}
210
+ type="button"
211
+ onClick={() => {
212
+ trackSession();
213
+ onSendMessage(f.prompt);
214
+ }}
215
+ className="px-3 py-1.5 rounded-full bg-slate-800/80 border border-slate-700/60 text-xs font-medium text-slate-300 hover:text-teal-200 hover:border-teal-500/50 hover:bg-teal-500/10 transition-all"
216
+ >
217
+ {f.prompt}
218
+ </button>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ <div ref={messagesEndRef} />
224
+ </div>
225
+
226
+ {/* Input Area */}
227
+ <div className="flex-shrink-0 border-t border-slate-200 dark:border-slate-700/50 bg-white/95 dark:bg-slate-900/95 px-4 py-3 transition-colors">
228
+ <div className="flex items-center gap-2 chat-input-area">
229
+ <div className="flex-1 relative">
230
+ <input
231
+ ref={inputRef}
232
+ type="text"
233
+ value={input}
234
+ onChange={(e) => setInput(e.target.value)}
235
+ onKeyDown={handleKeyDown}
236
+ placeholder={ROTATING_PLACEHOLDERS[rotIdx]}
237
+ className="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700/50 rounded-xl
238
+ px-4 py-3 text-sm text-slate-800 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500
239
+ focus:outline-none focus:ring-2 focus:ring-medical-primary/50
240
+ focus:border-medical-primary/50 transition-all"
241
+ disabled={isLoading}
242
+ />
243
+ </div>
244
+
245
+ <VoiceInput onTranscript={handleVoiceTranscript} language={language} />
246
+
247
+ <button
248
+ onClick={handleSend}
249
+ disabled={!input.trim() || isLoading}
250
+ className="touch-target rounded-xl p-2.5 bg-gradient-to-br from-blue-500 to-teal-500 text-white
251
+ hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed
252
+ transition-all duration-200 active:scale-95 shadow-lg shadow-blue-500/20"
253
+ aria-label="Send message"
254
+ >
255
+ <Send size={20} />
256
+ </button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ );
261
+ }