github-actions[bot] commited on
Commit ·
18e4712
0
Parent(s):
Deploy MedOS Global from b5eca093
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +19 -0
- Dockerfile +60 -0
- README.md +95 -0
- app/admin/page.tsx +327 -0
- app/api/admin/stats/route.ts +46 -0
- app/api/admin/users/route.ts +78 -0
- app/api/auth/forgot-password/route.ts +44 -0
- app/api/auth/login/route.ts +51 -0
- app/api/auth/logout/route.ts +14 -0
- app/api/auth/me/route.ts +31 -0
- app/api/auth/register/route.ts +57 -0
- app/api/auth/resend-verification/route.ts +28 -0
- app/api/auth/reset-password/route.ts +63 -0
- app/api/auth/verify-email/route.ts +58 -0
- app/api/chat-history/route.ts +95 -0
- app/api/chat/route.ts +129 -0
- app/api/geo/route.ts +142 -0
- app/api/health-data/route.ts +113 -0
- app/api/health-data/sync/route.ts +75 -0
- app/api/health/route.ts +10 -0
- app/api/models/route.ts +14 -0
- app/api/og/route.tsx +207 -0
- app/api/rag/route.ts +30 -0
- app/api/sessions/route.ts +70 -0
- app/api/triage/route.ts +29 -0
- app/globals.css +397 -0
- app/layout.tsx +257 -0
- app/page.tsx +5 -0
- app/robots.ts +23 -0
- app/sitemap.ts +27 -0
- app/stats/page.tsx +245 -0
- app/symptoms/[slug]/page.tsx +237 -0
- app/symptoms/page.tsx +86 -0
- components/MedOSGlobalApp.tsx +317 -0
- components/chat/MessageBubble.tsx +281 -0
- components/chat/QuickChips.tsx +30 -0
- components/chat/RightPanel.tsx +134 -0
- components/chat/Sidebar.tsx +278 -0
- components/chat/TrustBar.tsx +27 -0
- components/chat/TypingIndicator.tsx +15 -0
- components/chat/VoiceInput.tsx +88 -0
- components/mobile/BottomNav.tsx +65 -0
- components/mobile/InstallPrompt.tsx +71 -0
- components/ui/DisclaimerBanner.tsx +27 -0
- components/ui/InstallPrompt.tsx +122 -0
- components/ui/OfflineBanner.tsx +33 -0
- components/ui/ShareButtons.tsx +59 -0
- components/ui/ThemeToggle.tsx +44 -0
- components/views/AboutView.tsx +118 -0
- 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 |
+
[](https://huggingface.co/spaces/ruslanmv/MediBot)
|
| 55 |
+
[](#)
|
| 56 |
+
[](#)
|
| 57 |
+
[](#)
|
| 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 & 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 · Powered by OllaBridge
|
| 179 |
+
</p>
|
| 180 |
+
<p className="text-[10px] text-slate-600">
|
| 181 |
+
Free & Open Source · 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">🏥</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">🏥</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 |
+
}
|