diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 6388447..1553d2f 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -27,8 +27,8 @@ jobs: run: | cd backend python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Run Tests run: | cd backend - # pytest (if tests are added) + pytest -q diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 13c5e56..92ecdf2 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -24,7 +24,10 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Install Dependencies - run: cd frontend && npm ci + # npm install (not npm ci) tolerates cross-platform optional-dependency + # lock drift — the lock is generated across Windows/macOS/Linux dev boxes + # and native bindings (Tailwind oxide, rolldown) differ per platform. + run: cd frontend && npm install - name: Build run: cd frontend && npm run build # - name: Test diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml index 4b53465..26ca74d 100644 --- a/.github/workflows/pr-guard.yml +++ b/.github/workflows/pr-guard.yml @@ -9,7 +9,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Check for merge conflicts + - name: Fail if leftover conflict markers are present run: | - echo "Checking merge conflicts..." - # Add conflict check logic here + echo "Scanning for unresolved merge conflict markers..." + if git grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude).github/**'; then + echo "::error::Unresolved merge conflict markers found. Resolve them before merging." + exit 1 + fi + echo "No conflict markers found." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfeabd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +serveo.log +surge.log +surge_redeploy.log +tunnel.log + +# Dependency directories +node_modules/ +jspm_packages/ + +# Build outputs +dist/ +dist-ssr/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +*.suo +*.ntvsca* +*.njsproj +*.sln +*.sw? + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.production + +# Python Virtual Environments & Caches +venv/ +.venv/ +env/ +ENV/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ipynb_checkpoints/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c816c40 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "tomoki1207.pdf" + ] +} diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 08d0bdc..3272ce0 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -10,7 +10,7 @@ This is the single most important document for the team. Both the backend (You) - The frontend **never** talks to the database directly. It only ever calls these endpoints. - Blood groups are always strings: `"O+"`, `"O-"`, `"A+"`, `"A-"`, `"B+"`, `"B-"`, `"AB+"`, `"AB-"`. - `status` for a hospital is always one of: `"pending"`, `"confirmed"`, `"declined"`. -- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur). +- Coordinates: `lat` and `lng` are decimal numbers (e.g. `26.9124`, `75.7873` for Jaipur). `lat` must be −90..90 and `lng` −180..180 — out-of-range coordinates (e.g. a failed browser geolocation) are rejected with **422**. ## Endpoint 1 — Trigger an emergency `POST /emergency` @@ -41,11 +41,14 @@ This is the single most important document for the team. Both the backend (You) "phone": "+91xxxxxxxxxx" } ], - "donors_alerted": 5 + "donors_alerted": 5, + "rare_group": false } ``` - `hospitals` is an array, already sorted best-first by the backend. - `donors_alerted` is just a count (integer) for the UI to display. +- `rare_group` (boolean) is `true` when the needed group is Rh-negative (scarce + supply); the UI can warn that compatible donors may be few. Safe to ignore. ## Endpoint 2 — Poll / subscribe to emergency status `GET /emergency/{request_id}/status` @@ -59,9 +62,35 @@ This is the single most important document for the team. Both the backend (You) { "hospital_id": "h2", "name": "Fortis Jaipur", "eta_minutes": 9, "status": "pending" } ], "donors_alerted": 5, - "donors_responded": 2 + "donors_responded": 2, + "unconfirmed_fallback": false +} +``` +- `unconfirmed_fallback` (boolean) becomes `true` once ~3 minutes pass with no + hospital confirmed. Cue for the UI to surface the nearest hospitals with a + 1-tap call button (the phones from the `POST /emergency` response). Safe to ignore. + +## Endpoint 3a — Hospital confirmation page details +`GET /confirm/{token}` + +Hit when the hospital contact **opens** their link, so the page can show the real +emergency details (instead of placeholders). + +**Backend returns:** +```json +{ + "hospital_name": "SMS Hospital", + "emergency_type": "trauma", + "blood_group": "O+", + "eta_minutes": 6, + "already_confirmed": false, + "responded": false, + "accepted": false } ``` +- `already_confirmed` is `true` if another hospital already took this patient. +- `responded` is `true` if this hospital has already replied; `accepted` is its choice. +- Unknown token → **404**. ## Endpoint 3 — Hospital taps Accept / Decline `POST /confirm/{token}` @@ -91,10 +120,14 @@ This is hit by the hospital confirmation page when the contact taps a button. "blood_group": "B+", "lat": 26.9200, "lng": 75.8000, - "last_donated": "2025-01-15" + "last_donated": "2025-01-15", + "sex": "female" } ``` `last_donated` is a date string "YYYY-MM-DD", or null if never donated. +`sex` is **optional** — `"male"` or `"female"`. It only sets the donation +cooldown (120 days for women, 90 otherwise). Omit it and the backend defaults to +90 days; existing forms need no change. **Backend returns:** ```json diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..d13eb4a --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,109 @@ +# Deploying GoldenHour (backend + database) + +Two ways to run in production. Pick one. + +- **Demo mode** — deploy the backend with no Supabase variables. It serves the + seeded in-memory store (25 hospitals, 180 donors). Every endpoint works; + nothing persists. Lowest-risk path for a hackathon demo. +- **Database mode** — back it with Supabase (PostgreSQL + PostGIS) for real + persistence and Supabase Realtime. Do the database steps below first. + +Check which mode is live any time with `GET /health` (`"mode": "supabase"` vs +`"in-memory"`) and confirm the DB is actually reachable with `GET /ready`. + +--- + +## 1. Database — Supabase (skip for demo mode) + +1. Create a project at [supabase.com](https://supabase.com) (remember the DB password). +2. **SQL Editor** → run [`backend/sql/schema.sql`](backend/sql/schema.sql) — creates + the 4 tables, the PostGIS spatial indexes, and the `donors_nearby` RPC. +3. **SQL Editor** → run [`backend/sql/seed.sql`](backend/sql/seed.sql) — loads the + 25 hospitals + 180 donors. (Runs as table owner, so it bypasses RLS — more + reliable than the anon-key Python seeder.) +4. Enable **Realtime** on the `confirmation_requests` table + (Database → Replication, or the table's Realtime toggle). This is what makes + the patient's screen flip live when a hospital accepts. +5. Collect credentials from **Project Settings**: + - **API** → Project URL → `SUPABASE_URL` + - **API** → `service_role` secret → `SUPABASE_SERVICE_KEY` (backend uses this) + - **API** → `anon` public key → `SUPABASE_ANON_KEY` (frontend uses this) + - **Database** → Connection string (URI) → `DATABASE_URL` + +> Alternative to step 3: seed from your machine with +> `cd backend && python seed_supabase.py` (needs `SUPABASE_URL` + +> `SUPABASE_SERVICE_KEY` in `.env`). Prefer the SQL editor if anon RLS blocks inserts. + +--- + +## 2. Backend — Render + +The repo ships a blueprint at [`render.yaml`](render.yaml). + +1. [render.com](https://render.com) → **New → Blueprint** → connect the + `GoldenHour` repo. Render reads `render.yaml` automatically. +2. Set the `sync: false` environment variables: + + | Variable | Demo mode | Database mode | + | --- | --- | --- | + | `SUPABASE_URL` | *(blank)* | from Supabase | + | `SUPABASE_SERVICE_KEY` | *(blank)* | service_role secret | + | `SUPABASE_ANON_KEY` | *(blank)* | anon key (optional for backend) | + | `DATABASE_URL` | *(blank)* | Supabase connection URI | + | `GOOGLE_MAPS_API_KEY` | *(blank → estimated ETAs)* | optional, real ETAs | + | `MSG91_AUTH_KEY` | *(blank)* | optional, SMS | + | `FRONTEND_URL` | your frontend URL | your frontend URL | + | `CORS_ORIGINS` | `*` or frontend URL | frontend URL | + +3. **Create** and wait for the build. The API is live at + `https://goldenhour-api.onrender.com` (or your chosen name). + +The same image runs on Fly.io, or locally: `docker compose up --build`. + +--- + +## 3. Verify + +```bash +# Liveness + which mode is active +curl https://goldenhour-api.onrender.com/health +# {"status":"ok","version":"0.1.0","mode":"supabase"} + +# Readiness — actually pings the database (503 if unreachable) +curl https://goldenhour-api.onrender.com/ready +# {"ready":true,"backend":"supabase"} + +# Smoke-test the main flow +curl -X POST https://goldenhour-api.onrender.com/emergency \ + -H "Content-Type: application/json" \ + -d '{"lat":26.9124,"lng":75.7873,"emergency_type":"trauma","blood_group":"O+"}' +``` + +Interactive API docs are at `…/docs`. + +--- + +## 4. Git flow (you are the Git owner) + +Backend work lands on `backend-dev`; you merge to `main`: + +```bash +git checkout main +git pull origin main +git merge backend-dev +git push origin main +``` + +Your teammate works only on `frontend-dev` and pushes there; you merge it into +`main` the same way. Their app reaches this backend via +`VITE_API_BASE_URL=https://goldenhour-api.onrender.com`. + +--- + +## Security note + +The backend connects with the **service-role** key (`SUPABASE_SERVICE_KEY`), +which bypasses RLS — keep it server-side only, never in the frontend. To harden +the database, enable RLS with read-only `anon` policies (see the commented block +at the end of [`backend/sql/schema.sql`](backend/sql/schema.sql)); backend writes +still succeed via the service-role key. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f7d87a1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,50 @@ +# GoldenHour Visual Identity & Design System + +GoldenHour's visual language balances **calm urgency**—trustworthy like a medical device, fast like a 911 dispatch console. The interface is optimized for mobile-first, one-handed, glove-friendly usage during critical moments. + +--- + +## 🎨 Color Palette + +| Token | CSS Variable | Hex Code | Purpose | +| :--- | :--- | :--- | :--- | +| **bg** | `--color-bg` | `#FBFAF8` | Warm paper background for readability | +| **surface** | `--color-surface` | `#FFFFFF` | Card backgrounds, separated by soft shadows (no harsh borders) | +| **ink** | `--color-ink` | `#1A1714` | High-contrast readable typography | +| **muted** | `--color-ink-muted` | `#6B6560` | Secondary metadata and labels | +| **emergency** | `--color-emergency` | `#DC2626` | Primary CTAs, urgent warnings | +| **pressed** | `--color-emergency-pressed` | `#B91C1C` | Active/Pressed states for emergency actions | +| **success** | `--color-success` | `#059669` | Confirmed responses, lock status | +| **pending** | `--color-pending` | `#9CA3AF` | Warm gray for loading, pending, inactive states | +| **golden hour** | `--color-accent` | `#F59E0B` | Brand mark and subtle highlighting | + +--- + +## 📐 Shape & Spacing + +* **Border Radius:** + * Cards: `rounded-2xl` (`1rem`) + * Buttons: `rounded-xl` (`0.75rem`) +* **Touch Targets:** + * Primary Action Buttons: Minimum height of `56px` (`h-14`) for easy tap states. +* **Shadows:** + * Soft layered shadows to create depth without borders: + ```css + box-shadow: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04); + ``` + +--- + +## ✍️ Typography + +* **Font Family:** Inter throughout (tightly tracked headings). +* **Headings:** Bold, tight-tracked (`tracking-tight`), text sizes `text-3xl` or `text-4xl`. +* **Body:** Generous line heights for readability. + +--- + +## 🎬 Motion + +* **Buttons:** Click/tap feedback scale down (`scale-95`). +* **Transitions:** Spring transitions for status/state changes (`framer-motion`). +* **Card Entrances:** Staggered fade and slide-up transition. diff --git a/README.md b/README.md index 9484b4e..a1bf279 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,190 @@ +

+ GoldenHour — one request, the right hospital and ready blood, inside the hour that decides +

+ +

+ License: MIT + Python 3.12 + FastAPI + Pydantic v2 + Supabase + PostGIS + Tests: 31 passing + PWA: React 19 +

+ # GoldenHour -GoldenHour is a Progressive Web Application and feature-phone SMS service that converts a single GPS-triggered emergency request into two simultaneous parallel actions: -1. Locating and confirming a hospital with the correct department and a free bed. -2. Alerting nearby replacement-blood donors of a compatible blood group. +> One tap. Two lifelines. The right hospital and the right blood — before the car even moves. + +**GoldenHour** is a Progressive Web App + feature-phone SMS service that converts +a single GPS-triggered emergency request into two simultaneous, parallel actions: + +1. **Locate and confirm a hospital** with the correct department and a free bed. +2. **Alert nearby replacement-blood donors** of a compatible blood group. + +It is built for the **self-transporting family in India** — the majority of +emergency patients who travel to hospital by private car or auto, entirely +outside any ambulance system. The "golden hour" after a trauma is when minutes +decide outcomes; GoldenHour spends those minutes finding a hospital that can +actually take the patient instead of driving to one that can't. + +**Tags:** `Healthcare Technology` · `Social Impact` · `Emergency Response` · `Bharat Academix CodeQuest 2026` · `FastAPI` · `React` · `Supabase` · `PostGIS` · `PWA` + +--- + +## How it works (end-to-end) + +

+ One request triggers two parallel actions: hospital ranking and confirmation, plus donor matching and alerting +

+ +A single `POST /emergency` fans out into both branches at once. On the **hospital** +side, the backend ranks nearby hospitals and sends each a one-tap confirmation +link; the **first to Accept "takes" the patient**, and later acceptances are told +the patient is already routed. On the **blood** side, it matches compatible donors +within range and alerts the nearest, directing them to the hospital's licensed +blood bank. The patient's screen updates live the instant a hospital confirms. + +--- + +## System architecture + +GoldenHour is a two-part system split cleanly by ownership, sharing one contract. +The backend itself is a strict three-layer design with two interchangeable data +stores behind a single `get_store()` — so the demo runs with **zero external +services** and the production path is a true drop-in, no service-code changes: + +

+ Three layers — HTTP routes, services, data — with InMemoryStore and SupabaseStore behind get_store() +

+ +- **`API_CONTRACT.md`** is the single source of truth both sides build against. + The backend's `schemas.py` and the frontend's `fetch()` calls mirror it exactly. +- **The frontend never talks to the database** — only to the API. +- **The backend runs with zero external services in demo mode** (in-memory store + seeded with Jaipur hospitals + donors), and swaps to Supabase/PostGIS, Google + Maps, and an SMS gateway when their env vars are present — no code changes. + +See [`backend/README.md`](backend/README.md) for the service-layer architecture +and the three algorithms, and [`frontend/README.md`](frontend/README.md) for the +screens and live-update wiring. + +--- + +## Repository structure + +``` +GoldenHour/ +├── API_CONTRACT.md # SHARED contract — exact API request/response shapes +├── README.md # You are here +├── LICENSE +├── docs/ # README diagrams (banner, flow, architecture SVGs) +├── render.yaml # Backend deploy config (Render) +├── vercel.json # Frontend deploy config (Vercel) +├── docker-compose.yml # One-command local backend (demo mode) +│ +├── backend/ # FastAPI service — DB, algorithms, integration +│ ├── main.py # Routes (thin HTTP layer) +│ ├── middleware.py # CORS +│ ├── config.py # Env-driven settings + demo fallbacks +│ ├── schemas.py # Pydantic models (= API_CONTRACT.md) +│ ├── blood.py # ABO/Rh compatibility table +│ ├── geo.py # Haversine distance +│ ├── store.py # Data layer (InMemoryStore + SupabaseStore) +│ ├── seed_data.py # ~25 hospitals + ~180 donors (deterministic) +│ ├── seed_supabase.py # Seed a real Supabase DB +│ ├── services/ # Business logic (ranking, matching, confirm, …) +│ ├── sql/schema.sql # PostGIS tables, indexes, RPC +│ ├── tests/ # pytest (algorithms + contract + live flow) +│ ├── Dockerfile +│ └── .env.example +│ +├── frontend/ # React + Vite + Tailwind PWA +│ ├── index.html # Shell + meta/description/OG/PWA tags +│ ├── src/ +│ │ ├── App.jsx # Mobile shell + routes +│ │ └── components/ # PatientIntake, PatientResults, HospitalConfirm, DonorRegistration +│ ├── vite.config.js +│ └── package.json +│ +└── .github/workflows/ # CI: backend tests, frontend CI, PR guard +``` + +--- + +## Tech stack + +| Layer | Technology | +| --- | --- | +| Frontend | React 19, Vite, Tailwind CSS, React Router, Supabase JS client | +| Backend | Python 3.12, FastAPI, Uvicorn, Pydantic v2 | +| Database | PostgreSQL + PostGIS (via Supabase), Supabase Realtime | +| Maps / ETA | Google Maps Distance Matrix API | +| Messaging | Console / Telegram (demo), MSG91 / Gupshup + DLT (production) | +| Infra | Docker, Render (API), Vercel (PWA), GitHub Actions CI | + +--- + +## Quickstart + +### Backend (demo mode — no setup, no database) + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Open , trigger an emergency, and watch the hospital +confirmation links print to the console (`GET /dev/links`) and the donor alerts +(`GET /dev/alerts`). Run the tests with `pytest`. + +Or, with Docker, from the repo root: `docker compose up --build`. + +### Frontend + +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Point it at the API by creating `frontend/.env.local` with +`VITE_API_BASE_URL=http://localhost:8000`. + +--- + +## API at a glance + +Full shapes in [`API_CONTRACT.md`](API_CONTRACT.md). + +| Method & path | Purpose | +| --- | --- | +| `POST /emergency` | Trigger — rank hospitals, alert donors, send confirmation links (`rare_group` flag) | +| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (`unconfirmed_fallback` flag) | +| `POST /confirm/{token}` | Hospital taps Accept / Not Available | +| `POST /donor/register` | Register a replacement-blood donor (optional `sex` for cooldown) | +| `POST /sms/inbound` | Feature-phone SMS path (stretch) | +| `GET /health` · `GET /ready` | Liveness (mode) · readiness (pings the DB) | +| `GET /dev/links` · `GET /dev/alerts` | Demo: links sent to hospitals · alerts sent to donors | + +--- -It is built for the self-transporting family in India — the majority of emergency patients who travel to hospital by private car or auto, entirely outside any ambulance system. +## Deployment -## Project Structure +| Component | Platform | Config | +| --- | --- | --- | +| Backend API | Render (or Fly.io) | [`render.yaml`](render.yaml) — Python runtime, `rootDir: backend` | +| Frontend PWA | Vercel | [`vercel.json`](vercel.json) — builds `frontend/`, SPA rewrites | +| Database | Supabase | Run [`backend/sql/schema.sql`](backend/sql/schema.sql), enable Realtime | -- `backend/`: FastAPI backend with PostgreSQL + PostGIS (via Supabase) orchestrating routing, confirmation logic, and donor matching. -- `frontend/`: React + Vite + Tailwind CSS PWA containing the Patient App, Hospital Confirmation Page, and Donor Registration interfaces. +Production secrets (`SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, `MSG91_AUTH_KEY`, +`VITE_API_BASE_URL`) are set in the respective dashboards — never committed. +See [`DEPLOY.md`](DEPLOY.md) for the full Supabase + Render runbook. -## Setup +--- -See `backend/README.md` and `frontend/README.md` (to be created) for specific setup instructions. +## License -## Tags -Healthcare Technology, Social Impact, Bharat Academix CodeQuest 2026, FastApi, React, Supabase, PostGIS +[MIT](LICENSE). diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a990f74 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.env +.venv/ +venv/ +tests/ +*.md diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..78dd0e9 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,42 @@ +# GoldenHour backend configuration. +# Copy to `.env` and fill in to enable each subsystem. With everything blank the +# API runs in demo mode (in-memory store, estimated ETAs, console link delivery). + +# --- Supabase / PostgreSQL + PostGIS --------------------------------------- +# Set SUPABASE_URL + a key to switch the data layer onto the real database. +SUPABASE_URL= +SUPABASE_ANON_KEY= +# Trusted server-side key — preferred over the anon key for the backend +# (bypasses RLS). Keep secret; never expose to the frontend. +SUPABASE_SERVICE_KEY= +# Direct Postgres connection (used by seed_supabase.py / migrations). +DATABASE_URL= + +# --- Google Maps Distance Matrix (real drive-time ETAs) -------------------- +GOOGLE_MAPS_API_KEY= + +# --- SMS gateway (MSG91 / Gupshup) — stretch goal -------------------------- +MSG91_AUTH_KEY= + +# --- Confirmation link delivery -------------------------------------------- +# Public URL of the frontend; confirmation links are FRONTEND_URL/confirm/ +FRONTEND_URL=http://localhost:5173 +# console | telegram | msg91 +DELIVERY_CHANNEL=console +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# --- CORS ------------------------------------------------------------------ +# Comma-separated allowed origins; * for local dev. +CORS_ORIGINS=* + +# --- Algorithm tuning (optional; sensible defaults baked in) ---------------- +RANKING_TOP_N=5 +ETA_MAX_MINUTES=30 +DONOR_RADIUS_METERS=5000 +DONOR_COOLDOWN_DAYS=90 +DONOR_COOLDOWN_DAYS_FEMALE=120 +DONOR_ALERT_K=10 +REL_ACTIVATION_THRESHOLD=20 +UNCONFIRMED_FALLBACK_SECONDS=180 +CITY_AVG_SPEED_KMPH=22 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fb0ed8c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Environment / secrets — never commit real keys +.env +.env.* +!.env.example + +# Editor / OS +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e32b277 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +# GoldenHour API — single-stage image. One-command setup per the PRD. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# Install deps first for better layer caching. +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +# App code. +COPY . . + +EXPOSE 8000 + +# Render/Fly inject $PORT; default to 8000 locally. +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..628d315 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,253 @@ +# GoldenHour — Backend + +FastAPI service that turns one GPS-triggered emergency into two parallel actions: + +1. **Find a hospital** with the right department and a free bed, ranked best-first, and ask each to confirm. +2. **Alert nearby compatible blood donors** who are off their post-donation cooldown. + +It owns everything the user doesn't see: the database, the ranking and matching +algorithms, the confirmation/messaging flow, and live updates to the frontend. + +> **Runs with zero setup.** With no environment variables the API boots in +> **demo mode** — an in-memory store seeded with ~25 Jaipur hospitals and ~180 +> donors, estimated ETAs, and console link delivery. Add Supabase / Google Maps +> / SMS credentials to switch each subsystem to the real service. No code +> changes, no database required for the demo or for CI. + +--- + +## Tech stack + +| Piece | Tool | Why | +| --- | --- | --- | +| Language | Python 3.12 | — | +| API framework | FastAPI + Uvicorn | Async, fast, auto OpenAPI docs | +| Validation | Pydantic v2 | Request/response shapes = the API contract | +| Database (prod) | PostgreSQL + PostGIS via Supabase | Geography radius queries + Realtime | +| Maps / ETA (prod) | Google Maps Distance Matrix | Real drive-time ETA | +| Phone parsing | `phonenumbers` | Normalise donor numbers to E.164 | +| Container | Docker | One-command setup | + +--- + +## Architecture + +A strict three-layer design — HTTP in, services in the middle, data at the bottom: + +

+ Three layers — HTTP routes, services, data — with InMemoryStore and SupabaseStore behind get_store() +

+ +``` + HTTP (FastAPI routes) ── main.py + │ validate with schemas.py, call ONE service, return a schema + ▼ + Service layer ── services/ + │ ranking · matching · confirmation · orchestration · ETA · SMS + ▼ + Data layer ── store.py + in-memory now (seed_data.py) · Supabase-ready (sql/schema.sql) +``` + +**Rules that keep it clean:** + +- **Routes are thin.** Every handler in `main.py` validates input with a schema, + calls exactly one service function, and returns a schema. No business logic. +- **Services never touch HTTP.** They take a `store` + plain values and return + plain dicts, so they're trivially unit-testable without a client. +- **All data access goes through `store.py`.** Its method surface mirrors what a + Supabase-backed store would expose (`compatible_donors_nearby`, + `create_emergency`, …), so the demo store and a real DB store are + interchangeable behind `get_store()`. +- **Schemas are the contract.** `schemas.py` mirrors `API_CONTRACT.md` field for + field. Change one, change both, tell the frontend owner. + +### Two interchangeable stores + +`store.py` ships **both** a `InMemoryStore` and a `SupabaseStore` implementing the +identical method surface; `get_store()` picks one from configuration (Supabase +when `SUPABASE_URL`/`SUPABASE_ANON_KEY` are set and reachable, in-memory +otherwise — with automatic fallback if the client can't be created, so boot +never hard-fails). The radius + scoring maths is identical whether it runs in +Python (haversine) or PostGIS (`ST_DWithin`/`ST_Distance`), so the demo runs +end-to-end with no external services, CI needs no database, and the production +path (`sql/schema.sql` + `seed_supabase.py`) is a true drop-in — no service code +changes. + +--- + +## Repository structure + +``` +backend/ +├── main.py # FastAPI app + routes (thin HTTP layer) +├── middleware.py # CORS (origins from settings) +├── config.py # Settings from env; demo-mode fallbacks +├── schemas.py # Pydantic request/response models (= API_CONTRACT.md) +├── blood.py # ABO/Rh compatibility table +├── geo.py # Haversine distance helpers +├── store.py # Data layer: InMemoryStore + SupabaseStore + get_store() +├── seed_data.py # Deterministic ~25 hospitals + ~180 donors +├── seed_supabase.py # Push seed data into a real Supabase DB (anon/service key) +├── build_seed_sql.py # Generate sql/seed.sql from seed_data.py +├── services/ +│ ├── emergency_service.py # Orchestrates POST /emergency + status +│ ├── hospital_service.py # Hospital ranking algorithm +│ ├── donor_service.py # Blood matching + donor registration +│ ├── confirm_service.py # Accept/decline race resolution +│ ├── geocoder.py # ETA (Google Maps or estimate) + area geocode +│ ├── sms_service.py # Link delivery + inbound SMS parse +│ ├── realtime_service.py # Supabase Realtime publish (no-op in demo) +│ ├── phone_utils.py # E.164 phone normalisation +│ ├── cache.py # Tiny TTL cache +│ └── rate_limiter.py # Fixed-window rate limiter +├── sql/ +│ ├── schema.sql # PostGIS tables, indexes, donors_nearby() RPC, RLS notes +│ └── seed.sql # Generated SQL seed (run after schema.sql) +├── tests/ # pytest: units + endpoint/contract/flow tests +├── requirements.txt +├── Dockerfile +└── .env.example +``` + +--- + +## API endpoints + +All shapes are defined by [`../API_CONTRACT.md`](../API_CONTRACT.md). Interactive +docs are served at `/docs` (Swagger) and `/redoc` when the app is running. + +| Method & path | Purpose | +| --- | --- | +| `POST /emergency` | Trigger an emergency — rank hospitals, alert donors, send confirmation links. | +| `GET /emergency/{request_id}/status` | Live status — hospital replies + donor responses (poll or Supabase Realtime). | +| `POST /confirm/{token}` | A hospital contact taps Accept (`true`) / Not Available (`false`). | +| `POST /donor/register` | Register a replacement-blood donor. | +| `POST /sms/inbound` | Feature-phone path (stretch): parse an SMS, reply with nearest hospitals. | +| `GET /health` | Liveness + which mode the app is running in (cheap, no I/O). | +| `GET /ready` | Readiness — pings the data layer; 503 if the DB is unreachable. | +| `GET /dev/links` | Demo helper: the confirmation links recently "sent" to hospitals. | +| `GET /dev/alerts` | Demo helper: the blood-needed alerts recently "sent" to donors. | + +--- + +## The three algorithms + +**1. Hospital ranking** (`services/hospital_service.py`) — for the needed +department, score each nearby hospital and return the top N: + +``` +P(h) = 0.5 · prox(h) + 0.3 · dept(h) + 0.2 · rel(h) + +prox(h) = 1 − (eta / eta_max), clamped to [0, 1] # closer is better +dept(h) = 1 if the needed department is present else 0 +rel(h) = live acceptance rate # historically reliable +``` + +**Reliability cold start.** `rel(h)` only enters the score once a hospital has +logged ≥ `REL_ACTIVATION_THRESHOLD` (default 20) confirmations — we have no +honest reliability signal before then. Until a hospital crosses that bar the +score degrades to `prox + dept`, renormalised to span [0, 1]. At launch (no +confirmations yet) ranking is pure proximity + department. If nothing confirms +within ~3 minutes the status response flags `unconfirmed_fallback` so the UI can +surface the nearest hospitals with a 1-tap call — never a false "bed available." + +**2. Blood matching** (`services/donor_service.py` + `blood.py`) — compatible +donor groups (hardcoded ABO table), available, within 5 km, and off the +sex-aware cooldown (90 days men / 120 days women — `last_donated IS NULL OR +last_donated < today − cooldown`), nearest first. The nearest top-K are then +alerted (`services/sms_service.py`) with directions to the hospital's **licensed +blood bank**, not the emergency ward. Rh-negative requests are flagged `rare_group`. + +**3. Confirmation flow** (`services/confirm_service.py`) — the first hospital to +accept "takes" the patient; any later acceptance gets `already_confirmed: true` +and does not override the winner. The accepted hospital's status flips +`pending → confirmed`, which the frontend sees via polling or Realtime. + +--- + +## Running it + +### Local (demo mode, no setup) + +```bash +cd backend +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Open . Trigger an emergency and watch the +confirmation links print to the console (or hit `GET /dev/links`). + +### Docker + +```bash +# from repo root +docker compose up --build # backend on :8000 + +# or just the backend image +cd backend && docker build -t goldenhour-api . && docker run -p 8000:8000 goldenhour-api +``` + +### With a real Supabase + Maps stack + +1. Create a Supabase project; run [`sql/schema.sql`](sql/schema.sql) in the SQL editor. +2. Run [`sql/seed.sql`](sql/seed.sql) in the SQL editor to load hospitals + donors + (or `python seed_supabase.py` from your machine). +3. Enable **Realtime** on the `confirmation_requests` table. +4. Copy `.env.example` → `.env` and fill in `SUPABASE_URL`, `SUPABASE_SERVICE_KEY` + (preferred for the backend), `GOOGLE_MAPS_API_KEY` (and `MSG91_AUTH_KEY` for SMS). +5. Start the app — `GET /health` reports `"mode": "supabase"`; `GET /ready` + confirms the DB is reachable. + +See [`../DEPLOY.md`](../DEPLOY.md) for the full Render + Supabase runbook. + +--- + +## Configuration + +Everything is environment-driven (see [`.env.example`](.env.example)). Highlights: + +| Variable | Default | Effect | +| --- | --- | --- | +| `SUPABASE_URL` + a key | — | Set URL + a key → use the real database. | +| `SUPABASE_SERVICE_KEY` | — | Preferred backend key (bypasses RLS); falls back to anon. | +| `SUPABASE_ANON_KEY` | — | Anon key; used if no service key is set. | +| `GOOGLE_MAPS_API_KEY` | — | Set → real drive-time ETAs (else estimated). | +| `DELIVERY_CHANNEL` | `console` | `console` \| `telegram` \| `msg91`. | +| `FRONTEND_URL` | `http://localhost:5173` | Base for `/confirm/` links. | +| `CORS_ORIGINS` | `*` | Comma-separated allowed origins. | +| `DONOR_RADIUS_METERS` | `5000` | Donor search radius. | +| `DONOR_COOLDOWN_DAYS` | `90` | Post-donation cooldown (men / default). | +| `DONOR_COOLDOWN_DAYS_FEMALE` | `120` | Post-donation cooldown for women. | +| `DONOR_ALERT_K` | `10` | Nearest matched donors actually alerted per emergency. | +| `REL_ACTIVATION_THRESHOLD` | `20` | Confirmations a hospital needs before `rel(h)` counts. | +| `UNCONFIRMED_FALLBACK_SECONDS` | `180` | No-confirmation window before status flags the 1-tap-call fallback. | +| `RANKING_TOP_N` | `5` | Hospitals returned per emergency. | + +--- + +## Testing + +```bash +cd backend +pip install -r requirements-dev.txt +pytest # 30 tests: algorithms + endpoint contract + live flow + Supabase path +``` + +The suite runs fully offline (no Supabase, no network) and verifies the exact +`API_CONTRACT.md` response shapes, the ranking/matching algorithms, and the +end-to-end emergency → confirm → status flip. The **`SupabaseStore` production +path is also tested** against a fake supabase client +(`tests/fake_supabase.py`), so column names, query chains, and key mapping are +verified without a live database. CI runs the same suite on Python 3.11 and 3.12. + +--- + +## Deployment + +[`../render.yaml`](../render.yaml) defines the Render web service (Python runtime, +`rootDir: backend`, `uvicorn main:app`). Set the `SUPABASE_*`, `GOOGLE_MAPS_API_KEY`, +and `MSG91_AUTH_KEY` env vars in the Render dashboard. The same Docker image runs +on Fly.io. diff --git a/backend/blood.py b/backend/blood.py new file mode 100644 index 0000000..c3e954c --- /dev/null +++ b/backend/blood.py @@ -0,0 +1,45 @@ +"""ABO/Rh blood-group compatibility. + +For a patient who *needs* a given blood group, which donor groups can safely +donate? This is the hardcoded "ABO table" referenced in the backend PRD. It is +intentionally a plain dictionary — there is no clinical edge case here worth a +database lookup, and keeping it in code makes the donor query trivially fast. +""" + +from __future__ import annotations + +from typing import List + +ALL_GROUPS: List[str] = ["O-", "O+", "A-", "A+", "B-", "B+", "AB-", "AB+"] + +# Rh-negative groups are scarce in India (~5% of the population). Flag them so +# the UI can warn that compatible donors may be few and rare-group blood banks +# should be contacted in parallel. +RARE_GROUPS = frozenset({"O-", "A-", "B-", "AB-"}) + + +def is_rare_group(recipient_group: str) -> bool: + """True when the needed blood group is Rh-negative (scarce supply).""" + return recipient_group in RARE_GROUPS + + +# recipient blood group -> list of donor groups that may donate to them +_COMPATIBLE_DONORS = { + "O-": ["O-"], + "O+": ["O-", "O+"], + "A-": ["O-", "A-"], + "A+": ["O-", "O+", "A-", "A+"], + "B-": ["O-", "B-"], + "B+": ["O-", "O+", "B-", "B+"], + "AB-": ["O-", "A-", "B-", "AB-"], + "AB+": ALL_GROUPS, # universal recipient +} + + +def compatible_donor_groups(recipient_group: str) -> List[str]: + """Donor blood groups that can give to ``recipient_group``. + + Unknown groups return an empty list so callers fail safe (alert nobody) + rather than alerting everybody. + """ + return _COMPATIBLE_DONORS.get(recipient_group, []) diff --git a/backend/build_seed_sql.py b/backend/build_seed_sql.py new file mode 100644 index 0000000..3f61235 --- /dev/null +++ b/backend/build_seed_sql.py @@ -0,0 +1,85 @@ +"""Generate sql/seed.sql from seed_data.py. + +Run from the backend/ directory: python build_seed_sql.py + +Produces an idempotent SQL seed (INSERT ... ON CONFLICT DO NOTHING) that can be +pasted into the Supabase SQL editor right after sql/schema.sql. Running it in the +SQL editor executes as the table owner, so it bypasses RLS — more reliable than +seeding over the anon key with seed_supabase.py. +""" + +from __future__ import annotations + +import seed_data + + +def _esc(text: str) -> str: + return text.replace("'", "''") + + +def _array_literal(items) -> str: + inner = ", ".join("'%s'" % _esc(i) for i in items) + return f"ARRAY[{inner}]::text[]" + + +def build() -> str: + out = [ + "-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand).", + "-- Run AFTER sql/schema.sql. Idempotent: re-running is safe.", + "", + "-- Hospitals -----------------------------------------------------------------", + "insert into hospitals (id, name, lat, lng, departments, beds_available, " + "avg_response_rate, phone, contact_phone) values", + ] + rows = [] + for h in seed_data.hospitals(): + rows.append( + " ('{id}', '{name}', {lat}, {lng}, {depts}, {beds}, {rel}, '{phone}', '{contact}')".format( + id=h["id"], + name=_esc(h["name"]), + lat=h["lat"], + lng=h["lng"], + depts=_array_literal(h["departments"]), + beds=h["beds_available"], + rel=h["avg_response_rate"], + phone=h["phone"], + contact=h["contact_phone"], + ) + ) + out.append(",\n".join(rows)) + out.append("on conflict (id) do nothing;") + out.append("") + + out += [ + "-- Donors --------------------------------------------------------------------", + "insert into blood_donors (id, name, phone, blood_group, lat, lng, " + "last_donated, sex, available) values", + ] + rows = [] + for d in seed_data.donors(): + last = "'%s'" % d["last_donated"] if d["last_donated"] else "null" + sex = "'%s'" % d["sex"] if d.get("sex") else "null" + rows.append( + " ('{id}', '{name}', '{phone}', '{bg}', {lat}, {lng}, {last}, {sex}, {av})".format( + id=d["id"], + name=_esc(d["name"]), + phone=d["phone"], + bg=d["blood_group"], + lat=d["lat"], + lng=d["lng"], + last=last, + sex=sex, + av=str(d["available"]).lower(), + ) + ) + out.append(",\n".join(rows)) + out.append("on conflict (id) do nothing;") + out.append("") + return "\n".join(out) + + +if __name__ == "__main__": + sql = build() + with open("sql/seed.sql", "w", encoding="utf-8", newline="\n") as f: + f.write(sql) + print("Wrote sql/seed.sql") diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..01e4789 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,112 @@ +"""Application configuration. + +All runtime configuration is read from environment variables so the same image +runs locally, in CI, and in production (Render/Fly) without code changes. + +Nothing here is required: with an empty environment the app boots in +**demo mode** — an in-memory store seeded with Jaipur hospitals and donors, a +haversine ETA estimator, and console link delivery. Set the Supabase / Google +Maps / MSG91 variables to switch each subsystem over to the real service. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import List + +from dotenv import load_dotenv + +load_dotenv() # loads backend/.env into os.environ before Settings reads it + + +def _env_list(name: str, default: str) -> List[str]: + raw = os.getenv(name, default) + return [item.strip() for item in raw.split(",") if item.strip()] + + +@dataclass(frozen=True) +class Settings: + # --- Core --------------------------------------------------------------- + app_name: str = "GoldenHour API" + version: str = "0.1.0" + + # --- Supabase / PostgreSQL + PostGIS ----------------------------------- + # When a URL + a key are present the app talks to the real database; + # otherwise it falls back to the in-memory store (demo mode). + supabase_url: str = os.getenv("SUPABASE_URL", "") + supabase_key: str = os.getenv("SUPABASE_ANON_KEY", "") + # Trusted server-side key. Preferred over the anon key for the backend's + # reads/writes (bypasses RLS); keep it secret — never ship it to a client. + supabase_service_key: str = os.getenv("SUPABASE_SERVICE_KEY", "") + database_url: str = os.getenv("DATABASE_URL", "") + + # --- Google Maps Distance Matrix (ETA + geocoding) --------------------- + google_maps_api_key: str = os.getenv("GOOGLE_MAPS_API_KEY", "") + + # --- SMS gateway (MSG91 / Gupshup) — stretch goal ---------------------- + msg91_auth_key: str = os.getenv("MSG91_AUTH_KEY", "") + + # --- Algorithm tuning --------------------------------------------------- + ranking_top_n: int = int(os.getenv("RANKING_TOP_N", "5")) + eta_max_minutes: float = float(os.getenv("ETA_MAX_MINUTES", "30")) + donor_radius_meters: float = float(os.getenv("DONOR_RADIUS_METERS", "5000")) + # Post-donation cooldown, differentiated by sex per national blood-donation + # guidelines: 90 days (men) / 120 days (women). + donor_cooldown_days: int = int(os.getenv("DONOR_COOLDOWN_DAYS", "90")) + donor_cooldown_days_female: int = int( + os.getenv("DONOR_COOLDOWN_DAYS_FEMALE", "120") + ) + # Nearest matched donors actually alerted per emergency (top-K). + donor_alert_k: int = int(os.getenv("DONOR_ALERT_K", "10")) + # rel(h) enters the hospital score only after a hospital has logged this many + # confirmations; until then ranking degrades to proximity + department. + rel_activation_threshold: int = int(os.getenv("REL_ACTIVATION_THRESHOLD", "20")) + # Seconds with no confirmation before status flags the 1-tap-call fallback. + unconfirmed_fallback_seconds: int = int( + os.getenv("UNCONFIRMED_FALLBACK_SECONDS", "180") + ) + city_avg_speed_kmph: float = float(os.getenv("CITY_AVG_SPEED_KMPH", "22")) + + # --- OSM startup pre-warm ---------------------------------------------- + # Semicolon-separated "lat,lng" pairs fetched from Overpass at boot (demo + # mode only). Pre-warms the in-memory hospital store so the first emergency + # in those areas is instant instead of waiting on an on-demand OSM fetch. + # Example: OSM_SEED_COORDS=26.14,91.81;27.47,94.91 + osm_seed_coords: str = os.getenv("OSM_SEED_COORDS", "") + + # --- Link / message delivery ------------------------------------------- + # Where the hospital confirmation link points. The frontend serves + # /confirm/, so this is the public site URL. + frontend_url: str = os.getenv("FRONTEND_URL", "http://localhost:5173") + # "console" (print the link), "telegram", or "msg91". + delivery_channel: str = os.getenv("DELIVERY_CHANNEL", "console") + telegram_bot_token: str = os.getenv("TELEGRAM_BOT_TOKEN", "") + telegram_chat_id: str = os.getenv("TELEGRAM_CHAT_ID", "") + + # --- CORS --------------------------------------------------------------- + cors_origins: List[str] = field( + default_factory=lambda: _env_list("CORS_ORIGINS", "*") + ) + + @property + def supabase_active_key(self) -> str: + """Key the backend connects with — service-role preferred, anon fallback.""" + return self.supabase_service_key or self.supabase_key + + @property + def use_supabase(self) -> bool: + """True when a real Supabase backend is configured.""" + return bool(self.supabase_url and self.supabase_active_key) + + @property + def use_google_maps(self) -> bool: + return bool(self.google_maps_api_key) + + def summary(self) -> str: + mode = "supabase" if self.use_supabase else "in-memory (demo)" + eta = "google-maps" if self.use_google_maps else "haversine-estimate" + return f"store={mode} eta={eta} delivery={self.delivery_channel}" + + +settings = Settings() diff --git a/backend/geo.py b/backend/geo.py new file mode 100644 index 0000000..26fc3ed --- /dev/null +++ b/backend/geo.py @@ -0,0 +1,29 @@ +"""Geospatial helpers. + +In production the radius queries run inside PostGIS (``ST_DWithin`` / +``ST_Distance``). In demo mode we reproduce the same maths in Python with the +haversine formula so the app behaves identically without a database. +""" + +from __future__ import annotations + +import math + +EARTH_RADIUS_M = 6_371_000.0 + + +def haversine_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """Great-circle distance between two lat/lng points, in metres.""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + d_phi = math.radians(lat2 - lat1) + d_lambda = math.radians(lng2 - lng1) + + a = ( + math.sin(d_phi / 2) ** 2 + + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + ) + return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + return haversine_meters(lat1, lng1, lat2, lng2) / 1000.0 diff --git a/backend/main.py b/backend/main.py index e2b2bc0..6cdbade 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,65 +1,265 @@ -from fastapi import FastAPI -from pydantic import BaseModel -from typing import List, Optional +"""GoldenHour API — FastAPI application entrypoint. + +Thin HTTP layer: each route validates input with a schema, calls one service, +and returns a schema. All business logic lives in ``services/``; all data access +lives in ``store.py``. Endpoint shapes match ``API_CONTRACT.md`` exactly. +""" + +import asyncio +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException + +from config import settings from middleware import add_cors_middleware +from schemas import ( + DonorRegisterRequest, + DonorRegisterResponse, + EmergencyRequest, + EmergencyResponse, + EmergencyStatusResponse, + HealthResponse, + HospitalConfirmDetailsResponse, + HospitalConfirmRequest, + HospitalConfirmResponse, + SmsInboundRequest, + SmsInboundResponse, +) +from store import get_store + +from services import confirm_service, donor_service, emergency_service, sms_service + +logging.basicConfig(level=logging.INFO) +logging.getLogger("httpx").setLevel(logging.WARNING) # suppress URLs (contain API key) +logger = logging.getLogger("goldenhour") + + +async def _prewarm_osm() -> None: + """Fetch hospitals from Overpass at startup for OSM_SEED_COORDS locations. -app = FastAPI(title="GoldenHour API") + Runs as a background task so the API is immediately ready — the pre-warm + completes in the background. Only fires in in-memory (demo) mode; Supabase + already has real data. + """ + from services.hospital_service import _fetch_hospitals_nearby as _fetch_from_osm + + store = get_store() + if not hasattr(store, "bulk_add_hospitals"): + return # Supabase store — skip + if not settings.osm_seed_coords: + return + + for pair in settings.osm_seed_coords.split(";"): + pair = pair.strip() + if not pair: + continue + try: + lat_s, lng_s = pair.split(",") + lat, lng = float(lat_s.strip()), float(lng_s.strip()) + except ValueError: + logger.warning( + "OSM_SEED_COORDS: invalid pair %r — expected 'lat,lng'", pair + ) + continue + if store.is_region_fetched(lat, lng): + continue + hospitals = await _fetch_from_osm(lat, lng) + if hospitals: + store.bulk_add_hospitals(hospitals) + store.mark_region_fetched(lat, lng) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + get_store() # warm the store (seed in-memory, or connect Supabase) + logger.info("GoldenHour API ready - %s", settings.summary()) + asyncio.create_task(_prewarm_osm()) + yield + + +# OpenAPI tag groups — describe each section shown in /docs and /redoc. +TAGS_METADATA = [ + { + "name": "emergency", + "description": "Trigger an emergency and poll its live status. One request " + "fans out into hospital ranking + blood-donor matching.", + }, + { + "name": "confirmation", + "description": "A hospital contact accepts or declines a patient via a " + "one-tap link. First to accept wins; later accepts are told it is taken.", + }, + { + "name": "donors", + "description": "Register replacement-blood donors (sex sets the cooldown).", + }, + { + "name": "sms", + "description": "Feature-phone fallback — parse an inbound SMS, reply with " + "the nearest hospitals.", + }, + { + "name": "meta", + "description": "Health, readiness, and demo helpers (`/dev/links`, " + "`/dev/alerts`).", + }, +] + +app = FastAPI( + title=settings.app_name, + version=settings.version, + summary="Emergency hospital routing + replacement-blood donor alerting.", + description=( + "One GPS-triggered request finds a hospital with the right department and " + "a free bed **and** alerts nearby compatible blood donors — two parallel " + "actions inside the golden hour.\n\n" + "Runs in **demo mode** with zero external services (seeded in-memory store); " + "set the Supabase / Google Maps / SMS env vars to switch to the real stack." + ), + contact={ + "name": "GoldenHour — Bharat Academix CodeQuest 2026", + "url": "https://github.com/SarmaHighOnCode/GoldenHour", + }, + license_info={ + "name": "MIT", + "url": "https://github.com/SarmaHighOnCode/GoldenHour/blob/main/LICENSE", + }, + openapi_tags=TAGS_METADATA, + lifespan=lifespan, +) add_cors_middleware(app) -class EmergencyRequest(BaseModel): - lat: float - lng: float - emergency_type: str - blood_group: str -class HospitalConfirmRequest(BaseModel): - accepted: bool +@app.get("/", tags=["meta"], summary="Service info") +async def root(): + return {"service": settings.app_name, "version": settings.version, "docs": "/docs"} -class DonorRegisterRequest(BaseModel): - name: str - phone: str - blood_group: str - lat: float - lng: float - last_donated: Optional[str] = None -class SmsInboundRequest(BaseModel): - from_number: str - body: str +@app.get( + "/health", response_model=HealthResponse, tags=["meta"], summary="Liveness probe" +) +async def health_check(): + """Liveness — cheap, no I/O (safe for frequent platform health checks).""" + mode = "supabase" if settings.use_supabase else "in-memory" + return HealthResponse(status="ok", version=settings.version, mode=mode) -@app.post("/emergency") + +@app.get("/ready", tags=["meta"], summary="Readiness probe (pings the data layer)") +async def readiness_check(): + """Readiness — verifies the data layer actually answers (pings the DB). + + Use this after a deploy to confirm the API is really connected to Supabase + and not silently running in demo mode. Returns 503 if the store is unreachable. + """ + store = get_store() + if not store.ping(): + raise HTTPException(status_code=503, detail="data layer unreachable") + return {"ready": True, "backend": store.backend} + + +@app.post( + "/emergency", + response_model=EmergencyResponse, + tags=["emergency"], + summary="Trigger an emergency", +) async def trigger_emergency(request: EmergencyRequest): - return { - "request_id": "abc123", - "hospitals": [], - "donors_alerted": 0 - } + """Trigger an emergency: rank hospitals, alert donors, send confirmations.""" + store = get_store() + return await emergency_service.trigger_emergency( + store, + lat=request.lat, + lng=request.lng, + emergency_type=request.emergency_type, + blood_group=request.blood_group, + ) -@app.get("/emergency/{request_id}/status") + +@app.get( + "/emergency/{request_id}/status", + response_model=EmergencyStatusResponse, + tags=["emergency"], + summary="Live emergency status", +) async def get_emergency_status(request_id: str): - return { - "request_id": request_id, - "hospitals": [], - "donors_alerted": 0, - "donors_responded": 0 - } - -@app.post("/confirm/{token}") + """Poll/subscribe target: live hospital replies + donor responses.""" + try: + return emergency_service.get_status(get_store(), request_id) + except LookupError: + raise HTTPException(status_code=404, detail="Unknown request_id") + + +@app.get( + "/confirm/{token}", + response_model=HospitalConfirmDetailsResponse, + tags=["confirmation"], + summary="Confirmation details for the hospital page", +) +async def get_confirmation(token: str): + """The emergency details a hospital sees when it opens its one-tap link.""" + try: + return confirm_service.get_confirmation_details(get_store(), token) + except confirm_service.ConfirmationNotFound: + raise HTTPException(status_code=404, detail="Unknown confirmation token") + + +@app.post( + "/confirm/{token}", + response_model=HospitalConfirmResponse, + tags=["confirmation"], + summary="Hospital accept / decline", +) async def confirm_hospital(token: str, request: HospitalConfirmRequest): - return { - "ok": True, - "hospital_name": "Test Hospital", - "already_confirmed": False - } + """A hospital contact taps Accept (true) or Not Available (false).""" + try: + return confirm_service.handle_confirmation(get_store(), token, request.accepted) + except confirm_service.ConfirmationNotFound: + raise HTTPException(status_code=404, detail="Unknown confirmation token") + -@app.post("/donor/register") +@app.post( + "/donor/register", + response_model=DonorRegisterResponse, + tags=["donors"], + summary="Register a blood donor", +) async def register_donor(request: DonorRegisterRequest): - return {"ok": True, "donor_id": "d123"} + """Register a replacement-blood donor.""" + donor_id = donor_service.register_donor( + get_store(), + name=request.name, + phone=request.phone, + blood_group=request.blood_group, + lat=request.lat, + lng=request.lng, + last_donated=request.last_donated, + sex=request.sex, + ) + return DonorRegisterResponse(ok=True, donor_id=donor_id) -@app.post("/sms/inbound") + +@app.post( + "/sms/inbound", + response_model=SmsInboundResponse, + tags=["sms"], + summary="Inbound SMS (feature phone)", +) async def handle_sms_inbound(request: SmsInboundRequest): - return {"reply": "Nearest hospitals: ..."} + """Feature-phone path (stretch): parse an inbound SMS and reply.""" + reply = sms_service.handle_inbound( + get_store(), from_number=request.from_number, body=request.body + ) + return SmsInboundResponse(reply=reply) -@app.get("/health") -async def health_check(): - return {"status": "ok", "version": "0.1.0"} + +@app.get("/dev/links", tags=["meta"], summary="Recent hospital links (demo)") +async def dev_links(): + """Demo helper: the confirmation links recently 'sent' to hospitals.""" + return {"links": sms_service.recent_links} + + +@app.get("/dev/alerts", tags=["meta"], summary="Recent donor alerts (demo)") +async def dev_alerts(): + """Demo helper: the blood-needed alerts recently 'sent' to donors.""" + return {"alerts": sms_service.recent_alerts} diff --git a/backend/middleware.py b/backend/middleware.py index a8f5188..fddc90b 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,11 +1,23 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -def add_cors_middleware(app: FastAPI): +from config import settings + + +def add_cors_middleware(app: FastAPI) -> None: + """Allow the frontend (served from a different origin) to call the API. + + Origins come from the ``CORS_ORIGINS`` env var (comma-separated), defaulting + to ``*`` for local development. Credentials are only enabled when an + explicit origin list is configured, since browsers reject + ``allow_credentials`` together with the ``*`` wildcard. + """ + origins = settings.cors_origins + wildcard = origins == ["*"] app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=origins, + allow_credentials=not wildcard, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..555b5b9 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +# Tests import top-level modules (main, store, services...) directly, so run +# from the backend/ directory. `pythonpath = .` makes that work from anywhere. +pythonpath = . +testpaths = tests +addopts = -q +filterwarnings = + ignore::DeprecationWarning diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4c0d345 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +# Test/dev-only dependencies (kept out of the runtime image). +-r requirements.txt +pytest==8.3.3 diff --git a/backend/requirements.txt b/backend/requirements.txt index 7875867..0a7bcb5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ pydantic==2.8.2 httpx==0.27.0 phonenumbers==8.13.40 supabase==2.5.1 +python-dotenv==1.0.1 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..e471f7a --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,131 @@ +"""Pydantic request/response models. + +These mirror ``API_CONTRACT.md`` **exactly** — the field names and shapes here +are the contract the frontend builds against. Do not rename a field without +updating ``API_CONTRACT.md`` and telling the frontend owner first. +""" + +from __future__ import annotations + +from typing import Annotated, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +EmergencyType = Literal["trauma", "cardiac", "obstetric", "general"] +BloodGroup = Literal["O+", "O-", "A+", "A-", "B+", "B-", "AB+", "AB-"] +HospitalStatus = Literal["pending", "confirmed", "declined"] + +# GPS coordinates, range-checked so a failed/garbage browser geolocation is +# rejected with 422 rather than producing nonsense distances. +Latitude = Annotated[float, Field(ge=-90.0, le=90.0)] +Longitude = Annotated[float, Field(ge=-180.0, le=180.0)] + + +# --- POST /emergency ------------------------------------------------------- +class EmergencyRequest(BaseModel): + lat: Latitude + lng: Longitude + emergency_type: EmergencyType + blood_group: BloodGroup + + +class HospitalCard(BaseModel): + hospital_id: str + name: str + eta_minutes: int + department_match: bool + distance_km: float + status: HospitalStatus + phone: str + + +class EmergencyResponse(BaseModel): + request_id: str + hospitals: List[HospitalCard] + donors_alerted: int + # True when the needed group is Rh-negative (scarce supply) — the UI warns + # that compatible donors may be few and rare-group banks should be contacted. + rare_group: bool = False + + +# --- GET /emergency/{request_id}/status ------------------------------------ +class HospitalStatusCard(BaseModel): + hospital_id: str + name: str + eta_minutes: int + status: HospitalStatus + + +class EmergencyStatusResponse(BaseModel): + request_id: str + hospitals: List[HospitalStatusCard] + donors_alerted: int + donors_responded: int + # True after the no-confirmation window elapses with nothing confirmed — cue + # for the UI to surface the nearest-hospitals 1-tap-call fallback. + unconfirmed_fallback: bool = False + + +# --- POST /confirm/{token} ------------------------------------------------- +class HospitalConfirmRequest(BaseModel): + accepted: bool + + +class HospitalConfirmResponse(BaseModel): + ok: bool + hospital_name: str + already_confirmed: bool + + +# --- GET /confirm/{token} -------------------------------------------------- +class HospitalConfirmDetailsResponse(BaseModel): + """What the hospital sees when it opens its one-tap link (before replying).""" + + hospital_name: str + emergency_type: str + blood_group: str + eta_minutes: int + # True when another hospital has already taken this patient. + already_confirmed: bool + # Whether this hospital has already replied, and if so what it chose. + responded: bool + accepted: bool + + +# --- POST /donor/register -------------------------------------------------- +class DonorRegisterRequest(BaseModel): + name: str + phone: str + blood_group: BloodGroup + lat: Latitude + lng: Longitude + last_donated: Optional[str] = None # "YYYY-MM-DD" or null + # Optional. Drives the post-donation cooldown: 120 days for "female", + # 90 days otherwise. Omitting it defaults to the 90-day window. + sex: Optional[Literal["male", "female"]] = None + + +class DonorRegisterResponse(BaseModel): + ok: bool + donor_id: str + + +# --- POST /sms/inbound (stretch) ------------------------------------------- +class SmsInboundRequest(BaseModel): + # The contract sends {"from": ..., "body": ...}; "from" is a reserved + # word in Python, so we expose it as ``from_number`` with an alias. + model_config = ConfigDict(populate_by_name=True) + + from_number: str = Field(alias="from") + body: str + + +class SmsInboundResponse(BaseModel): + reply: str + + +# --- GET /health ----------------------------------------------------------- +class HealthResponse(BaseModel): + status: str + version: str + mode: str diff --git a/backend/seed_data.py b/backend/seed_data.py new file mode 100644 index 0000000..6582484 --- /dev/null +++ b/backend/seed_data.py @@ -0,0 +1,215 @@ +"""Deterministic seed data for demo mode and for the SQL seed script. + +~25 real Jaipur hospitals with approximate coordinates and varied departments, +plus ~180 donors spread across all 8 blood groups and varied distances. The +``last_donated`` dates are deliberately spread so the sex-aware cooldown filter +(90 days men / 120 days women) visibly excludes some donors (the eligibility +logic actually does something in the demo). + +Generation is seeded, so every boot and every run of ``seed_supabase.py`` +produces the same dataset — reproducible demos, stable tests. +""" + +from __future__ import annotations + +import random +from datetime import date, timedelta +from typing import Dict, List + +from blood import ALL_GROUPS + +# Jaipur city centre — donors and hospitals scatter around this point. +JAIPUR_LAT = 26.9124 +JAIPUR_LNG = 75.7873 + +DEPARTMENTS = ["trauma", "cardiac", "obstetric", "general"] + +# name, lat, lng, departments, beds_available, avg_response_rate +_HOSPITALS = [ + ("SMS Hospital", 26.9036, 75.8147, ["trauma", "cardiac", "general"], 12, 0.92), + ("Fortis Escorts Jaipur", 26.8569, 75.8064, ["cardiac", "general"], 8, 0.88), + ( + "Manipal Hospital Jaipur", + 26.8853, + 75.7470, + ["trauma", "cardiac", "obstetric", "general"], + 15, + 0.90, + ), + ("Narayana Multispeciality", 26.8312, 75.8040, ["cardiac", "general"], 6, 0.81), + ("Eternal Hospital", 26.8665, 75.8101, ["cardiac", "trauma", "general"], 9, 0.86), + ( + "CK Birla Hospital (RBH)", + 26.8901, + 75.7686, + ["trauma", "obstetric", "general"], + 11, + 0.89, + ), + ("Mahatma Gandhi Hospital", 26.7700, 75.8550, ["trauma", "general"], 7, 0.78), + ( + "Apex Hospital Malviya Nagar", + 26.8541, + 75.8136, + ["general", "obstetric"], + 5, + 0.74, + ), + ( + "Santokba Durlabhji Memorial", + 26.9000, + 75.8060, + ["trauma", "cardiac", "general"], + 10, + 0.83, + ), + ("Jaipur Golden Hospital", 26.9200, 75.8000, ["general"], 4, 0.70), + ("Bhandari Hospital", 26.8950, 75.7400, ["trauma", "general"], 6, 0.76), + ( + "Soni Manipal Hospital", + 26.8650, + 75.7560, + ["cardiac", "obstetric", "general"], + 8, + 0.84, + ), + ("Cocoon Hospital", 26.8780, 75.7510, ["obstetric", "general"], 5, 0.79), + ("EHCC Hospital", 26.8492, 75.8024, ["cardiac", "trauma", "general"], 9, 0.85), + ("Rukmani Birla Hospital", 26.8895, 75.7690, ["obstetric", "general"], 7, 0.82), + ("Tagore Hospital", 26.8350, 75.7900, ["trauma", "general"], 6, 0.75), + ("Monilek Hospital", 26.8880, 75.8060, ["general", "obstetric"], 4, 0.72), + ("Imperial Hospital", 26.9300, 75.7700, ["general"], 3, 0.68), + ("Mital Hospital", 26.9080, 75.7990, ["general", "trauma"], 5, 0.73), + ("Getwell Hospital", 26.8700, 75.8200, ["general"], 4, 0.71), + ( + "Shalby Hospital Jaipur", + 26.8480, + 75.7720, + ["trauma", "cardiac", "general"], + 8, + 0.80, + ), + ("Dhanwantari Hospital", 26.9150, 75.8120, ["general", "obstetric"], 5, 0.74), + ("Pratap Memorial Hospital", 26.9250, 75.8200, ["general"], 3, 0.69), + ( + "Jeevan Rekha Hospital", + 26.8600, + 75.7980, + ["trauma", "cardiac", "general"], + 7, + 0.81, + ), + ("Marudhar Hospital", 26.9040, 75.8290, ["general", "obstetric"], 4, 0.72), +] + +_FIRST_NAMES = [ + "Asha", + "Rohan", + "Priya", + "Amit", + "Neha", + "Vikram", + "Pooja", + "Rahul", + "Sneha", + "Arjun", + "Kavya", + "Manish", + "Divya", + "Sanjay", + "Ritu", + "Karan", + "Anjali", + "Deepak", + "Meena", + "Suresh", + "Nisha", + "Gaurav", + "Sunita", + "Vivek", +] +_LAST_NAMES = [ + "Verma", + "Sharma", + "Gupta", + "Singh", + "Yadav", + "Meena", + "Agarwal", + "Jain", + "Saini", + "Choudhary", + "Kumawat", + "Soni", + "Rathore", + "Bhatt", + "Nair", +] + + +def _scatter(rng: random.Random, max_km: float) -> tuple[float, float]: + """A random point within ``max_km`` of Jaipur centre.""" + # ~111 km per degree latitude; longitude scaled by cos(latitude). + radius_deg = (max_km / 111.0) * (rng.random() ** 0.5) # sqrt for uniform area + angle = rng.uniform(0, 2 * 3.14159265) + import math + + dlat = radius_deg * math.cos(angle) + dlng = radius_deg * math.sin(angle) / math.cos(math.radians(JAIPUR_LAT)) + return round(JAIPUR_LAT + dlat, 5), round(JAIPUR_LNG + dlng, 5) + + +def hospitals() -> List[Dict]: + """The seeded hospital list, one dict per hospital.""" + out: List[Dict] = [] + for i, (name, lat, lng, depts, beds, rel) in enumerate(_HOSPITALS, start=1): + out.append( + { + "id": f"h{i}", + "name": name, + "lat": lat, + "lng": lng, + "departments": depts, + "beds_available": beds, + "avg_response_rate": rel, + "phone": f"+9114{i:07d}", + "contact_phone": f"+9198{i:08d}", + "confirm_token": f"hosp-{i:02d}-token", + } + ) + return out + + +def donors(count: int = 180, seed: int = 42) -> List[Dict]: + """``count`` seeded donors spread across all 8 blood groups.""" + rng = random.Random(seed) + today = date(2026, 6, 18) + out: List[Dict] = [] + for i in range(1, count + 1): + lat, lng = _scatter(rng, max_km=12.0) + group = ALL_GROUPS[i % len(ALL_GROUPS)] + sex = "female" if rng.random() < 0.5 else "male" + + # Spread donation history: ~25% never donated, the rest 10–200 days ago + # so some fall inside the cooldown (90d men / 120d women) and some don't. + roll = rng.random() + if roll < 0.25: + last_donated = None + else: + days_ago = rng.randint(10, 200) + last_donated = (today - timedelta(days=days_ago)).isoformat() + + out.append( + { + "id": f"d{i}", + "name": f"{rng.choice(_FIRST_NAMES)} {rng.choice(_LAST_NAMES)}", + "phone": f"+9199{i:08d}", + "blood_group": group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "sex": sex, + "available": True, + } + ) + return out diff --git a/backend/seed_supabase.py b/backend/seed_supabase.py new file mode 100644 index 0000000..b0746ec --- /dev/null +++ b/backend/seed_supabase.py @@ -0,0 +1,75 @@ +"""Seed the Supabase database with the demo hospitals and donors. + +Usage: + python seed_supabase.py # needs SUPABASE_URL + SUPABASE_ANON_KEY + +Run `sql/schema.sql` first to create the tables. This inserts the same ~25 +hospitals and ~180 donors used by demo mode, so the real DB and the in-memory +demo behave identically. + +(Demo mode does NOT need this — the in-memory store seeds itself on boot.) +""" + +from __future__ import annotations + +import sys + +import seed_data +from config import settings + + +def main() -> int: + if not settings.use_supabase: + print( + "SUPABASE_URL / SUPABASE_ANON_KEY are not set. Demo mode seeds itself " + "in-memory on boot — nothing to do here. Set them to seed a real DB." + ) + return 0 + + try: + from supabase import create_client + except ImportError: + print("The 'supabase' package is required: pip install supabase") + return 1 + + client = create_client(settings.supabase_url, settings.supabase_key) + + hospitals = [ + { + "id": h["id"], + "name": h["name"], + "lat": h["lat"], + "lng": h["lng"], + "departments": h["departments"], + "beds_available": h["beds_available"], + "avg_response_rate": h["avg_response_rate"], + "phone": h["phone"], + "contact_phone": h["contact_phone"], + } + for h in seed_data.hospitals() + ] + + donors = [ + { + "id": d["id"], + "name": d["name"], + "phone": d["phone"], + "blood_group": d["blood_group"], + "lat": d["lat"], + "lng": d["lng"], + "last_donated": d["last_donated"], + "available": d["available"], + } + for d in seed_data.donors() + ] + + print(f"Upserting {len(hospitals)} hospitals...") + client.table("hospitals").upsert(hospitals).execute() + print(f"Upserting {len(donors)} donors...") + client.table("blood_donors").upsert(donors).execute() + print("Done.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..df3427e --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,6 @@ +"""Service layer: business logic for GoldenHour. + +Each module owns one concern (hospital ranking, donor matching, confirmation +flow, geocoding/ETA, messaging, realtime, plus small infra helpers). Route +handlers in ``main.py`` call into these; data access goes through ``store.py``. +""" diff --git a/backend/services/cache.py b/backend/services/cache.py index 49cc8ef..0f5d98f 100644 Binary files a/backend/services/cache.py and b/backend/services/cache.py differ diff --git a/backend/services/confirm_service.py b/backend/services/confirm_service.py index 49cc8ef..d75fd76 100644 Binary files a/backend/services/confirm_service.py and b/backend/services/confirm_service.py differ diff --git a/backend/services/donor_service.py b/backend/services/donor_service.py index 49cc8ef..50991f1 100644 Binary files a/backend/services/donor_service.py and b/backend/services/donor_service.py differ diff --git a/backend/services/emergency_service.py b/backend/services/emergency_service.py index 49cc8ef..003a032 100644 Binary files a/backend/services/emergency_service.py and b/backend/services/emergency_service.py differ diff --git a/backend/services/geocoder.py b/backend/services/geocoder.py index 49cc8ef..db5fe30 100644 Binary files a/backend/services/geocoder.py and b/backend/services/geocoder.py differ diff --git a/backend/services/hospital_service.py b/backend/services/hospital_service.py index 49cc8ef..5a22ebb 100644 Binary files a/backend/services/hospital_service.py and b/backend/services/hospital_service.py differ diff --git a/backend/services/phone_utils.py b/backend/services/phone_utils.py index 49cc8ef..c5f162e 100644 Binary files a/backend/services/phone_utils.py and b/backend/services/phone_utils.py differ diff --git a/backend/services/rate_limiter.py b/backend/services/rate_limiter.py index 49cc8ef..d678bc6 100644 Binary files a/backend/services/rate_limiter.py and b/backend/services/rate_limiter.py differ diff --git a/backend/services/realtime_service.py b/backend/services/realtime_service.py index 49cc8ef..6c1210a 100644 Binary files a/backend/services/realtime_service.py and b/backend/services/realtime_service.py differ diff --git a/backend/services/sms_service.py b/backend/services/sms_service.py index 49cc8ef..7717c11 100644 Binary files a/backend/services/sms_service.py and b/backend/services/sms_service.py differ diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql new file mode 100644 index 0000000..b6f91c9 --- /dev/null +++ b/backend/sql/schema.sql @@ -0,0 +1,143 @@ +-- GoldenHour database schema (PostgreSQL + PostGIS, via Supabase). +-- +-- Run once in the Supabase SQL editor. Stores lat/lng as plain columns (so the +-- seed script can insert them over PostgREST) and derives a PostGIS geography +-- column from them for fast radius queries. Enable Realtime on +-- `confirmation_requests` afterwards so hospital replies push to the frontend. + +create extension if not exists postgis; + +-- --------------------------------------------------------------------------- +-- hospitals +-- --------------------------------------------------------------------------- +create table if not exists hospitals ( + id text primary key, -- e.g. "h1" + name text not null, + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + departments text[] not null default '{}', -- {"trauma","cardiac","general"} + beds_available integer not null default 0, + avg_response_rate double precision not null default 0.0, + phone text, + contact_phone text +); + +-- --------------------------------------------------------------------------- +-- blood_donors +-- --------------------------------------------------------------------------- +create table if not exists blood_donors ( + id text primary key, -- e.g. "d42" + name text not null, + phone text not null, + blood_group text not null, -- "O+", "AB-", ... + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + last_donated date, -- null = never donated + sex text, -- 'male' | 'female' | null + available boolean not null default true +); + +-- --------------------------------------------------------------------------- +-- emergency_requests +-- --------------------------------------------------------------------------- +create table if not exists emergency_requests ( + id text primary key, -- request_id, e.g. "req0001" + emergency_type text not null, -- trauma|cardiac|obstetric|general + blood_group_needed text not null, + lat double precision not null, + lng double precision not null, + location geography(Point, 4326) + generated always as + (ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) stored, + status text not null default 'pending', -- pending|confirmed|resolved + donors_alerted integer not null default 0, + donors_responded integer not null default 0, + hospitals jsonb not null default '[]', -- ranked hospital-card snapshot + created_at timestamptz not null default now() +); + +-- --------------------------------------------------------------------------- +-- confirmation_requests +-- --------------------------------------------------------------------------- +create table if not exists confirmation_requests ( + id text primary key default gen_random_uuid()::text, + token text unique not null, -- the token in the /confirm URL + emergency_id text not null references emergency_requests(id) on delete cascade, + hospital_id text not null references hospitals(id), + hospital_name text, -- denormalised for the confirm page + sent_at timestamptz not null default now(), + replied_at timestamptz, -- null until the hospital replies + confirmed boolean -- null=pending, true/false on reply +); + +-- --------------------------------------------------------------------------- +-- Spatial indexes — make the radius queries fast. +-- --------------------------------------------------------------------------- +create index if not exists idx_donors_loc on blood_donors using gist (location); +create index if not exists idx_hosp_loc on hospitals using gist (location); +create index if not exists idx_conf_emergency on confirmation_requests (emergency_id); + +-- --------------------------------------------------------------------------- +-- RPC: compatible donors within a radius, off-cooldown, nearest first. +-- Call via supabase.rpc('donors_nearby', {...}). Mirrors donor_service logic. +-- --------------------------------------------------------------------------- +create or replace function donors_nearby( + p_lat double precision, + p_lng double precision, + p_groups text[], + p_radius_m double precision default 5000, + p_cooldown_days integer default 90, + p_cooldown_days_female integer default 120 +) +returns setof blood_donors +language sql +stable +as $$ + select * + from blood_donors d + where d.blood_group = any(p_groups) + and d.available = true + and (d.last_donated is null + or d.last_donated < current_date + - ((case when d.sex = 'female' + then p_cooldown_days_female + else p_cooldown_days end) || ' days')::interval) + and ST_DWithin( + d.location, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + p_radius_m) + order by ST_Distance( + d.location, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography); +$$; + +-- --------------------------------------------------------------------------- +-- Row-Level Security (RLS) +-- --------------------------------------------------------------------------- +-- Tables created above have RLS DISABLED by default, so the anon key can read +-- and write — convenient for the demo/prototype. +-- +-- Recommended production model: +-- * Backend uses the SERVICE-ROLE key (SUPABASE_SERVICE_KEY) — bypasses RLS, +-- so all writes (emergencies, confirmations, donor inserts) go through the +-- trusted server. +-- * Frontend uses the anon key with read-only access to just what it needs to +-- subscribe to via Realtime. +-- +-- To lock down, enable RLS and add read-only anon policies, e.g.: +-- +-- alter table confirmation_requests enable row level security; +-- create policy "anon reads confirmations" +-- on confirmation_requests for select to anon using (true); +-- +-- alter table emergency_requests enable row level security; +-- create policy "anon reads emergencies" +-- on emergency_requests for select to anon using (true); +-- +-- (Writes still succeed because the backend connects with the service-role key.) diff --git a/backend/sql/seed.sql b/backend/sql/seed.sql new file mode 100644 index 0000000..ed203eb --- /dev/null +++ b/backend/sql/seed.sql @@ -0,0 +1,215 @@ +-- GoldenHour seed data (AUTO-GENERATED from seed_data.py — do not edit by hand). +-- Run AFTER sql/schema.sql. Idempotent: re-running is safe. + +-- Hospitals ----------------------------------------------------------------- +insert into hospitals (id, name, lat, lng, departments, beds_available, avg_response_rate, phone, contact_phone) values + ('h1', 'SMS Hospital', 26.9036, 75.8147, ARRAY['trauma', 'cardiac', 'general']::text[], 12, 0.92, '+91140000001', '+919800000001'), + ('h2', 'Fortis Escorts Jaipur', 26.8569, 75.8064, ARRAY['cardiac', 'general']::text[], 8, 0.88, '+91140000002', '+919800000002'), + ('h3', 'Manipal Hospital Jaipur', 26.8853, 75.747, ARRAY['trauma', 'cardiac', 'obstetric', 'general']::text[], 15, 0.9, '+91140000003', '+919800000003'), + ('h4', 'Narayana Multispeciality', 26.8312, 75.804, ARRAY['cardiac', 'general']::text[], 6, 0.81, '+91140000004', '+919800000004'), + ('h5', 'Eternal Hospital', 26.8665, 75.8101, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.86, '+91140000005', '+919800000005'), + ('h6', 'CK Birla Hospital (RBH)', 26.8901, 75.7686, ARRAY['trauma', 'obstetric', 'general']::text[], 11, 0.89, '+91140000006', '+919800000006'), + ('h7', 'Mahatma Gandhi Hospital', 26.77, 75.855, ARRAY['trauma', 'general']::text[], 7, 0.78, '+91140000007', '+919800000007'), + ('h8', 'Apex Hospital Malviya Nagar', 26.8541, 75.8136, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000008', '+919800000008'), + ('h9', 'Santokba Durlabhji Memorial', 26.9, 75.806, ARRAY['trauma', 'cardiac', 'general']::text[], 10, 0.83, '+91140000009', '+919800000009'), + ('h10', 'Jaipur Golden Hospital', 26.92, 75.8, ARRAY['general']::text[], 4, 0.7, '+91140000010', '+919800000010'), + ('h11', 'Bhandari Hospital', 26.895, 75.74, ARRAY['trauma', 'general']::text[], 6, 0.76, '+91140000011', '+919800000011'), + ('h12', 'Soni Manipal Hospital', 26.865, 75.756, ARRAY['cardiac', 'obstetric', 'general']::text[], 8, 0.84, '+91140000012', '+919800000012'), + ('h13', 'Cocoon Hospital', 26.878, 75.751, ARRAY['obstetric', 'general']::text[], 5, 0.79, '+91140000013', '+919800000013'), + ('h14', 'EHCC Hospital', 26.8492, 75.8024, ARRAY['cardiac', 'trauma', 'general']::text[], 9, 0.85, '+91140000014', '+919800000014'), + ('h15', 'Rukmani Birla Hospital', 26.8895, 75.769, ARRAY['obstetric', 'general']::text[], 7, 0.82, '+91140000015', '+919800000015'), + ('h16', 'Tagore Hospital', 26.835, 75.79, ARRAY['trauma', 'general']::text[], 6, 0.75, '+91140000016', '+919800000016'), + ('h17', 'Monilek Hospital', 26.888, 75.806, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000017', '+919800000017'), + ('h18', 'Imperial Hospital', 26.93, 75.77, ARRAY['general']::text[], 3, 0.68, '+91140000018', '+919800000018'), + ('h19', 'Mital Hospital', 26.908, 75.799, ARRAY['general', 'trauma']::text[], 5, 0.73, '+91140000019', '+919800000019'), + ('h20', 'Getwell Hospital', 26.87, 75.82, ARRAY['general']::text[], 4, 0.71, '+91140000020', '+919800000020'), + ('h21', 'Shalby Hospital Jaipur', 26.848, 75.772, ARRAY['trauma', 'cardiac', 'general']::text[], 8, 0.8, '+91140000021', '+919800000021'), + ('h22', 'Dhanwantari Hospital', 26.915, 75.812, ARRAY['general', 'obstetric']::text[], 5, 0.74, '+91140000022', '+919800000022'), + ('h23', 'Pratap Memorial Hospital', 26.925, 75.82, ARRAY['general']::text[], 3, 0.69, '+91140000023', '+919800000023'), + ('h24', 'Jeevan Rekha Hospital', 26.86, 75.798, ARRAY['trauma', 'cardiac', 'general']::text[], 7, 0.81, '+91140000024', '+919800000024'), + ('h25', 'Marudhar Hospital', 26.904, 75.829, ARRAY['general', 'obstetric']::text[], 4, 0.72, '+91140000025', '+919800000025') +on conflict (id) do nothing; + +-- Donors -------------------------------------------------------------------- +insert into blood_donors (id, name, phone, blood_group, lat, lng, last_donated, sex, available) values + ('d1', 'Vivek Sharma', '+919900000001', 'O+', 26.99778, 75.80247, null, 'female', true), + ('d2', 'Priya Singh', '+919900000002', 'A-', 26.98169, 75.72479, '2026-06-01', 'female', true), + ('d3', 'Deepak Agarwal', '+919900000003', 'A+', 26.87061, 75.75233, '2025-12-11', 'male', true), + ('d4', 'Vikram Soni', '+919900000004', 'B-', 26.86942, 75.75702, null, 'male', true), + ('d5', 'Priya Agarwal', '+919900000005', 'B+', 26.90016, 75.86491, '2026-05-13', 'female', true), + ('d6', 'Ritu Saini', '+919900000006', 'AB-', 26.93173, 75.75645, '2025-12-04', 'male', true), + ('d7', 'Suresh Nair', '+919900000007', 'AB+', 26.94613, 75.76721, '2025-12-30', 'female', true), + ('d8', 'Rahul Rathore', '+919900000008', 'O-', 26.82367, 75.73473, null, 'male', true), + ('d9', 'Sneha Jain', '+919900000009', 'O+', 26.9634, 75.81865, null, 'female', true), + ('d10', 'Sneha Soni', '+919900000010', 'A-', 26.85547, 75.85988, null, 'female', true), + ('d11', 'Vivek Singh', '+919900000011', 'A+', 26.84986, 75.69323, null, 'male', true), + ('d12', 'Rahul Kumawat', '+919900000012', 'B-', 26.88065, 75.82097, '2026-01-17', 'male', true), + ('d13', 'Divya Yadav', '+919900000013', 'B+', 26.91947, 75.71872, '2026-03-20', 'female', true), + ('d14', 'Nisha Jain', '+919900000014', 'AB-', 26.93617, 75.77111, '2026-04-15', 'male', true), + ('d15', 'Vivek Saini', '+919900000015', 'AB+', 26.97083, 75.7483, '2026-04-06', 'female', true), + ('d16', 'Rahul Gupta', '+919900000016', 'O-', 26.91091, 75.69831, '2026-03-08', 'female', true), + ('d17', 'Nisha Gupta', '+919900000017', 'O+', 26.97732, 75.83409, null, 'female', true), + ('d18', 'Anjali Yadav', '+919900000018', 'A-', 26.82746, 75.83799, '2026-02-09', 'female', true), + ('d19', 'Deepak Rathore', '+919900000019', 'A+', 26.98071, 75.69562, '2025-12-16', 'female', true), + ('d20', 'Asha Soni', '+919900000020', 'B-', 26.87706, 75.73881, '2026-02-12', 'female', true), + ('d21', 'Amit Bhatt', '+919900000021', 'B+', 26.9039, 75.90036, null, 'male', true), + ('d22', 'Manish Rathore', '+919900000022', 'AB-', 26.95896, 75.7069, null, 'male', true), + ('d23', 'Kavya Jain', '+919900000023', 'AB+', 26.95401, 75.77326, '2026-01-06', 'male', true), + ('d24', 'Rahul Verma', '+919900000024', 'O-', 26.92601, 75.78001, '2026-03-22', 'male', true), + ('d25', 'Deepak Rathore', '+919900000025', 'O+', 26.86402, 75.76286, '2026-05-22', 'female', true), + ('d26', 'Anjali Bhatt', '+919900000026', 'A-', 26.89181, 75.75103, null, 'male', true), + ('d27', 'Pooja Soni', '+919900000027', 'A+', 26.9945, 75.76633, '2025-12-14', 'male', true), + ('d28', 'Ritu Sharma', '+919900000028', 'B-', 26.97273, 75.78524, '2026-01-27', 'male', true), + ('d29', 'Rahul Verma', '+919900000029', 'B+', 26.96193, 75.81093, '2026-01-09', 'female', true), + ('d30', 'Priya Saini', '+919900000030', 'AB-', 26.89283, 75.7636, '2026-03-16', 'female', true), + ('d31', 'Meena Choudhary', '+919900000031', 'AB+', 26.8867, 75.73565, null, 'female', true), + ('d32', 'Amit Kumawat', '+919900000032', 'O-', 26.92844, 75.70591, null, 'male', true), + ('d33', 'Nisha Kumawat', '+919900000033', 'O+', 26.84945, 75.82407, '2025-12-18', 'female', true), + ('d34', 'Pooja Singh', '+919900000034', 'A-', 26.88464, 75.80915, '2026-04-06', 'female', true), + ('d35', 'Ritu Rathore', '+919900000035', 'A+', 26.96279, 75.85577, '2026-05-20', 'female', true), + ('d36', 'Priya Nair', '+919900000036', 'B-', 26.81704, 75.75231, '2026-06-05', 'female', true), + ('d37', 'Rohan Gupta', '+919900000037', 'B+', 26.92042, 75.89216, '2026-02-26', 'female', true), + ('d38', 'Arjun Agarwal', '+919900000038', 'AB-', 26.97867, 75.78042, '2026-02-12', 'female', true), + ('d39', 'Neha Singh', '+919900000039', 'AB+', 26.90138, 75.68687, '2026-02-04', 'male', true), + ('d40', 'Rohan Verma', '+919900000040', 'O-', 26.97015, 75.7744, '2026-03-20', 'male', true), + ('d41', 'Anjali Sharma', '+919900000041', 'O+', 26.82978, 75.78564, null, 'male', true), + ('d42', 'Amit Nair', '+919900000042', 'A-', 27.00305, 75.83399, '2026-02-25', 'female', true), + ('d43', 'Gaurav Choudhary', '+919900000043', 'A+', 26.84063, 75.74376, null, 'female', true), + ('d44', 'Rahul Yadav', '+919900000044', 'B-', 26.87948, 75.87064, '2026-03-20', 'female', true), + ('d45', 'Asha Jain', '+919900000045', 'B+', 26.88027, 75.72008, '2026-05-21', 'female', true), + ('d46', 'Sneha Gupta', '+919900000046', 'AB-', 26.83378, 75.75046, '2026-01-30', 'female', true), + ('d47', 'Ritu Bhatt', '+919900000047', 'AB+', 26.98892, 75.70759, '2026-04-29', 'male', true), + ('d48', 'Asha Kumawat', '+919900000048', 'O-', 26.88658, 75.87184, '2026-01-24', 'male', true), + ('d49', 'Sneha Sharma', '+919900000049', 'O+', 26.88256, 75.89166, '2026-05-05', 'male', true), + ('d50', 'Sunita Meena', '+919900000050', 'A-', 26.90754, 75.67307, '2026-04-16', 'female', true), + ('d51', 'Priya Kumawat', '+919900000051', 'A+', 26.87997, 75.74644, '2026-05-26', 'female', true), + ('d52', 'Sneha Gupta', '+919900000052', 'B-', 26.90066, 75.8651, '2025-12-27', 'female', true), + ('d53', 'Priya Nair', '+919900000053', 'B+', 26.82419, 75.754, null, 'female', true), + ('d54', 'Neha Agarwal', '+919900000054', 'AB-', 26.96565, 75.86848, '2026-01-18', 'female', true), + ('d55', 'Manish Singh', '+919900000055', 'AB+', 26.89859, 75.82771, '2026-05-29', 'male', true), + ('d56', 'Suresh Soni', '+919900000056', 'O-', 26.86789, 75.7005, '2026-02-24', 'female', true), + ('d57', 'Sanjay Verma', '+919900000057', 'O+', 26.95084, 75.76697, '2026-04-24', 'male', true), + ('d58', 'Vivek Rathore', '+919900000058', 'A-', 26.95313, 75.76384, '2025-12-19', 'male', true), + ('d59', 'Karan Singh', '+919900000059', 'A+', 26.94149, 75.83813, '2026-05-30', 'male', true), + ('d60', 'Rahul Verma', '+919900000060', 'B-', 26.95449, 75.76075, '2026-04-11', 'female', true), + ('d61', 'Sneha Meena', '+919900000061', 'B+', 26.84185, 75.84596, null, 'female', true), + ('d62', 'Amit Nair', '+919900000062', 'AB-', 26.84245, 75.84453, '2026-06-01', 'male', true), + ('d63', 'Suresh Agarwal', '+919900000063', 'AB+', 26.95861, 75.8949, '2026-05-12', 'male', true), + ('d64', 'Divya Nair', '+919900000064', 'O-', 26.92682, 75.71788, '2026-05-10', 'female', true), + ('d65', 'Deepak Kumawat', '+919900000065', 'O+', 26.90996, 75.87932, null, 'male', true), + ('d66', 'Gaurav Nair', '+919900000066', 'A-', 26.90776, 75.6846, '2026-05-22', 'male', true), + ('d67', 'Anjali Yadav', '+919900000067', 'A+', 26.88812, 75.85142, '2026-03-24', 'male', true), + ('d68', 'Sanjay Kumawat', '+919900000068', 'B-', 26.87174, 75.87518, '2026-04-20', 'male', true), + ('d69', 'Divya Saini', '+919900000069', 'B+', 26.86617, 75.68178, '2026-03-23', 'male', true), + ('d70', 'Nisha Meena', '+919900000070', 'AB-', 26.87962, 75.89172, '2026-01-04', 'female', true), + ('d71', 'Vikram Kumawat', '+919900000071', 'AB+', 26.8435, 75.81669, '2025-12-02', 'female', true), + ('d72', 'Gaurav Yadav', '+919900000072', 'O-', 26.88107, 75.78388, '2026-04-09', 'male', true), + ('d73', 'Karan Choudhary', '+919900000073', 'O+', 26.9285, 75.84185, null, 'female', true), + ('d74', 'Sunita Soni', '+919900000074', 'A-', 27.0018, 75.83667, '2026-04-20', 'female', true), + ('d75', 'Sanjay Singh', '+919900000075', 'A+', 26.85829, 75.83159, '2026-05-12', 'female', true), + ('d76', 'Rahul Nair', '+919900000076', 'B-', 26.95617, 75.77403, null, 'male', true), + ('d77', 'Suresh Meena', '+919900000077', 'B+', 26.81653, 75.81747, '2026-01-16', 'male', true), + ('d78', 'Sanjay Bhatt', '+919900000078', 'AB-', 26.99419, 75.71279, '2026-01-30', 'male', true), + ('d79', 'Sneha Rathore', '+919900000079', 'AB+', 26.81534, 75.82578, '2026-02-13', 'female', true), + ('d80', 'Rahul Yadav', '+919900000080', 'O-', 26.87753, 75.74142, '2025-12-30', 'male', true), + ('d81', 'Priya Gupta', '+919900000081', 'O+', 26.89612, 75.70899, '2026-01-21', 'female', true), + ('d82', 'Sanjay Agarwal', '+919900000082', 'A-', 26.88125, 75.81887, null, 'female', true), + ('d83', 'Meena Soni', '+919900000083', 'A+', 26.85163, 75.80212, '2026-03-01', 'female', true), + ('d84', 'Arjun Rathore', '+919900000084', 'B-', 26.92346, 75.77575, '2026-03-10', 'male', true), + ('d85', 'Deepak Rathore', '+919900000085', 'B+', 26.96486, 75.73964, '2025-12-02', 'male', true), + ('d86', 'Divya Meena', '+919900000086', 'AB-', 26.92785, 75.87986, '2026-06-01', 'female', true), + ('d87', 'Suresh Saini', '+919900000087', 'AB+', 26.93874, 75.69265, '2026-05-07', 'male', true), + ('d88', 'Nisha Agarwal', '+919900000088', 'O-', 26.89844, 75.7996, null, 'male', true), + ('d89', 'Ritu Meena', '+919900000089', 'O+', 26.87372, 75.79794, '2026-04-15', 'female', true), + ('d90', 'Sneha Bhatt', '+919900000090', 'A-', 26.95809, 75.73897, '2026-02-21', 'female', true), + ('d91', 'Rahul Kumawat', '+919900000091', 'A+', 26.94311, 75.79152, '2026-03-11', 'male', true), + ('d92', 'Rahul Singh', '+919900000092', 'B-', 26.93971, 75.77888, null, 'female', true), + ('d93', 'Meena Singh', '+919900000093', 'B+', 26.84078, 75.71059, '2026-05-10', 'female', true), + ('d94', 'Amit Rathore', '+919900000094', 'AB-', 26.90951, 75.86991, '2025-12-07', 'female', true), + ('d95', 'Arjun Choudhary', '+919900000095', 'AB+', 27.00786, 75.76317, null, 'female', true), + ('d96', 'Meena Soni', '+919900000096', 'O-', 26.99832, 75.76138, '2026-05-20', 'female', true), + ('d97', 'Suresh Rathore', '+919900000097', 'O+', 26.91683, 75.89768, '2025-12-15', 'male', true), + ('d98', 'Gaurav Meena', '+919900000098', 'A-', 26.94982, 75.78303, '2026-02-19', 'male', true), + ('d99', 'Amit Agarwal', '+919900000099', 'A+', 26.89536, 75.76184, '2026-02-03', 'female', true), + ('d100', 'Vikram Soni', '+919900000100', 'B-', 26.84262, 75.69787, '2026-02-17', 'male', true), + ('d101', 'Ritu Agarwal', '+919900000101', 'B+', 26.86668, 75.7163, '2026-02-05', 'male', true), + ('d102', 'Priya Yadav', '+919900000102', 'AB-', 26.83028, 75.72683, null, 'female', true), + ('d103', 'Kavya Verma', '+919900000103', 'AB+', 26.91631, 75.90105, '2026-03-03', 'female', true), + ('d104', 'Kavya Yadav', '+919900000104', 'O-', 26.87789, 75.86324, '2026-04-03', 'female', true), + ('d105', 'Pooja Sharma', '+919900000105', 'O+', 26.88177, 75.67886, null, 'female', true), + ('d106', 'Karan Kumawat', '+919900000106', 'A-', 26.86821, 75.82034, null, 'male', true), + ('d107', 'Sunita Singh', '+919900000107', 'A+', 26.82598, 75.82002, '2026-02-25', 'female', true), + ('d108', 'Vivek Saini', '+919900000108', 'B-', 26.86026, 75.75441, '2026-02-20', 'female', true), + ('d109', 'Vivek Singh', '+919900000109', 'B+', 26.89425, 75.72061, '2026-05-09', 'female', true), + ('d110', 'Pooja Soni', '+919900000110', 'AB-', 26.90968, 75.71926, '2026-04-20', 'male', true), + ('d111', 'Amit Bhatt', '+919900000111', 'AB+', 26.90044, 75.70401, '2026-03-28', 'male', true), + ('d112', 'Deepak Gupta', '+919900000112', 'O-', 26.91912, 75.84018, null, 'female', true), + ('d113', 'Nisha Bhatt', '+919900000113', 'O+', 26.96828, 75.77709, '2026-05-07', 'male', true), + ('d114', 'Ritu Meena', '+919900000114', 'A-', 26.98738, 75.8505, '2026-02-06', 'female', true), + ('d115', 'Divya Jain', '+919900000115', 'A+', 26.95642, 75.80381, '2026-05-23', 'male', true), + ('d116', 'Meena Yadav', '+919900000116', 'B-', 26.89218, 75.76332, null, 'female', true), + ('d117', 'Suresh Singh', '+919900000117', 'B+', 26.91276, 75.82268, '2026-01-07', 'male', true), + ('d118', 'Sanjay Yadav', '+919900000118', 'AB-', 26.84284, 75.86021, '2026-01-09', 'male', true), + ('d119', 'Pooja Kumawat', '+919900000119', 'AB+', 26.98815, 75.82108, null, 'male', true), + ('d120', 'Priya Gupta', '+919900000120', 'O-', 26.8859, 75.7402, null, 'female', true), + ('d121', 'Arjun Soni', '+919900000121', 'O+', 26.90708, 75.78922, '2026-04-10', 'male', true), + ('d122', 'Sneha Rathore', '+919900000122', 'A-', 26.94893, 75.73753, null, 'female', true), + ('d123', 'Amit Saini', '+919900000123', 'A+', 26.83114, 75.72963, null, 'male', true), + ('d124', 'Rohan Gupta', '+919900000124', 'B-', 26.94279, 75.83359, null, 'female', true), + ('d125', 'Amit Jain', '+919900000125', 'B+', 26.83281, 75.72661, '2026-02-16', 'male', true), + ('d126', 'Ritu Sharma', '+919900000126', 'AB-', 26.88453, 75.69166, '2026-02-02', 'male', true), + ('d127', 'Priya Singh', '+919900000127', 'AB+', 26.97662, 75.72728, '2026-06-02', 'male', true), + ('d128', 'Sneha Choudhary', '+919900000128', 'O-', 26.96692, 75.68532, '2025-12-18', 'male', true), + ('d129', 'Vikram Choudhary', '+919900000129', 'O+', 26.91325, 75.763, '2026-03-29', 'female', true), + ('d130', 'Kavya Meena', '+919900000130', 'A-', 26.94044, 75.71369, '2026-02-24', 'male', true), + ('d131', 'Gaurav Agarwal', '+919900000131', 'A+', 26.96783, 75.70994, '2026-03-27', 'female', true), + ('d132', 'Amit Rathore', '+919900000132', 'B-', 26.81968, 75.75344, '2026-03-18', 'female', true), + ('d133', 'Ritu Agarwal', '+919900000133', 'B+', 26.84396, 75.78024, '2026-01-21', 'male', true), + ('d134', 'Rohan Singh', '+919900000134', 'AB-', 26.8874, 75.78404, '2026-02-15', 'male', true), + ('d135', 'Amit Verma', '+919900000135', 'AB+', 26.95038, 75.83324, '2026-02-04', 'female', true), + ('d136', 'Deepak Verma', '+919900000136', 'O-', 26.82975, 75.71172, null, 'female', true), + ('d137', 'Amit Kumawat', '+919900000137', 'O+', 26.97935, 75.8371, '2026-02-10', 'male', true), + ('d138', 'Sanjay Bhatt', '+919900000138', 'A-', 26.81379, 75.78843, '2026-03-31', 'male', true), + ('d139', 'Pooja Nair', '+919900000139', 'A+', 26.83845, 75.80193, null, 'female', true), + ('d140', 'Sanjay Meena', '+919900000140', 'B-', 26.91045, 75.69346, null, 'female', true), + ('d141', 'Meena Choudhary', '+919900000141', 'B+', 26.80803, 75.78161, '2026-03-24', 'male', true), + ('d142', 'Kavya Saini', '+919900000142', 'AB-', 26.80458, 75.79518, '2026-03-12', 'female', true), + ('d143', 'Rahul Choudhary', '+919900000143', 'AB+', 26.84471, 75.86113, '2025-12-12', 'male', true), + ('d144', 'Sunita Nair', '+919900000144', 'O-', 26.95374, 75.72831, '2026-02-07', 'female', true), + ('d145', 'Karan Verma', '+919900000145', 'O+', 26.83892, 75.85896, '2026-05-01', 'male', true), + ('d146', 'Ritu Sharma', '+919900000146', 'A-', 26.94991, 75.778, null, 'female', true), + ('d147', 'Neha Sharma', '+919900000147', 'A+', 26.83685, 75.81081, '2025-12-23', 'male', true), + ('d148', 'Priya Bhatt', '+919900000148', 'B-', 26.98509, 75.77133, '2025-12-24', 'female', true), + ('d149', 'Sunita Nair', '+919900000149', 'B+', 26.88404, 75.72551, '2025-12-30', 'male', true), + ('d150', 'Nisha Kumawat', '+919900000150', 'AB-', 26.97791, 75.71137, null, 'female', true), + ('d151', 'Nisha Soni', '+919900000151', 'AB+', 27.01613, 75.78542, '2026-05-14', 'male', true), + ('d152', 'Kavya Rathore', '+919900000152', 'O-', 26.81767, 75.82655, '2026-05-28', 'male', true), + ('d153', 'Sanjay Choudhary', '+919900000153', 'O+', 26.89627, 75.80961, null, 'female', true), + ('d154', 'Suresh Kumawat', '+919900000154', 'A-', 26.95038, 75.8779, '2026-03-03', 'female', true), + ('d155', 'Ritu Yadav', '+919900000155', 'A+', 26.95769, 75.75632, '2026-04-04', 'female', true), + ('d156', 'Vikram Sharma', '+919900000156', 'B-', 26.98346, 75.72856, '2026-01-20', 'female', true), + ('d157', 'Sunita Yadav', '+919900000157', 'B+', 26.8718, 75.85378, '2026-02-20', 'male', true), + ('d158', 'Rahul Agarwal', '+919900000158', 'AB-', 26.8889, 75.86489, '2026-05-12', 'male', true), + ('d159', 'Divya Yadav', '+919900000159', 'AB+', 26.83951, 75.74577, '2025-12-22', 'male', true), + ('d160', 'Vivek Jain', '+919900000160', 'O-', 26.91884, 75.77912, '2026-01-04', 'male', true), + ('d161', 'Manish Singh', '+919900000161', 'O+', 26.99327, 75.72393, null, 'male', true), + ('d162', 'Gaurav Bhatt', '+919900000162', 'A-', 26.84985, 75.72069, '2025-12-22', 'male', true), + ('d163', 'Ritu Verma', '+919900000163', 'A+', 26.94516, 75.813, null, 'male', true), + ('d164', 'Vikram Singh', '+919900000164', 'B-', 26.9033, 75.69557, '2026-02-22', 'female', true), + ('d165', 'Vikram Yadav', '+919900000165', 'B+', 26.8743, 75.7765, '2026-03-31', 'male', true), + ('d166', 'Amit Jain', '+919900000166', 'AB-', 27.00947, 75.74748, '2026-03-14', 'male', true), + ('d167', 'Gaurav Agarwal', '+919900000167', 'AB+', 26.97963, 75.87933, '2025-12-05', 'male', true), + ('d168', 'Sneha Saini', '+919900000168', 'O-', 26.94816, 75.67501, '2026-06-05', 'female', true), + ('d169', 'Nisha Meena', '+919900000169', 'O+', 26.88663, 75.81863, '2026-03-03', 'male', true), + ('d170', 'Kavya Nair', '+919900000170', 'A-', 26.91601, 75.827, '2026-01-16', 'female', true), + ('d171', 'Arjun Kumawat', '+919900000171', 'A+', 26.86176, 75.71152, '2025-12-11', 'male', true), + ('d172', 'Amit Sharma', '+919900000172', 'B-', 26.95651, 75.84692, '2026-02-02', 'male', true), + ('d173', 'Sunita Saini', '+919900000173', 'B+', 26.86147, 75.77357, '2025-11-30', 'female', true), + ('d174', 'Karan Choudhary', '+919900000174', 'AB-', 26.909, 75.7089, '2026-05-14', 'female', true), + ('d175', 'Ritu Singh', '+919900000175', 'AB+', 26.97828, 75.76407, '2026-02-15', 'female', true), + ('d176', 'Manish Verma', '+919900000176', 'O-', 26.99353, 75.85284, '2025-12-25', 'male', true), + ('d177', 'Priya Kumawat', '+919900000177', 'O+', 26.93759, 75.85839, '2026-02-12', 'female', true), + ('d178', 'Kavya Singh', '+919900000178', 'A-', 26.88054, 75.74439, null, 'male', true), + ('d179', 'Pooja Choudhary', '+919900000179', 'A+', 26.93715, 75.66998, '2026-01-18', 'female', true), + ('d180', 'Suresh Verma', '+919900000180', 'B-', 26.94672, 75.74616, null, 'female', true) +on conflict (id) do nothing; diff --git a/backend/store.py b/backend/store.py new file mode 100644 index 0000000..547a025 --- /dev/null +++ b/backend/store.py @@ -0,0 +1,539 @@ +"""Data layer. + +Two interchangeable stores implement the same method surface: + +* ``InMemoryStore`` — seeded with Jaipur hospitals + donors, haversine radius + queries. The default; runs with zero external services (demo mode, CI). +* ``SupabaseStore`` — PostgreSQL + PostGIS via Supabase. Used automatically when + ``SUPABASE_URL`` / ``SUPABASE_ANON_KEY`` are set; falls back to in-memory if the + client can't be created, so the API never hard-fails at boot. + +``get_store()`` picks the right one. Because the four logical tables +(``hospitals``, ``blood_donors``, ``emergency_requests``, +``confirmation_requests``) and every query method are identical across both, +the service layer is completely unaware of which store is live. +""" + +from __future__ import annotations + +import itertools +import logging +import uuid +from datetime import date, datetime, timezone +from typing import Dict, List, Optional + +import seed_data +from config import settings +from geo import haversine_km, haversine_meters + +logger = logging.getLogger("goldenhour.store") + + +def _parse_date(value: Optional[str]) -> Optional[date]: + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_dt(value) -> datetime: + """Coerce a Supabase timestamptz string (or datetime) to an aware datetime.""" + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + pass + return _now() + + +# =========================================================================== +# In-memory store (demo / CI) +# =========================================================================== +class InMemoryStore: + backend = "in-memory" + + def __init__(self) -> None: + self.hospitals: List[Dict] = seed_data.hospitals() + self.donors: List[Dict] = seed_data.donors() + self.emergencies: Dict[str, Dict] = {} + self.confirmations: Dict[str, Dict] = {} # keyed by token + self._emergency_seq = itertools.count(1) + self._donor_seq = itertools.count(len(self.donors) + 1) + # OSM lazy-fetch cache: grid cells already fetched (0.3° ≈ 33 km) + self._osm_regions: set = set() + # Demo-donor cache: grid cells already given a synthesized donor pool. + self._donor_regions: set = set() + + def _region_key(self, lat: float, lng: float) -> tuple: + return (round(lat / 0.3), round(lng / 0.3)) + + def is_region_fetched(self, lat: float, lng: float) -> bool: + return self._region_key(lat, lng) in self._osm_regions + + def mark_region_fetched(self, lat: float, lng: float) -> None: + self._osm_regions.add(self._region_key(lat, lng)) + + def bulk_add_hospitals(self, hospitals: List[Dict]) -> int: + existing = {h["id"] for h in self.hospitals} + added = 0 + for h in hospitals: + if h["id"] not in existing: + self.hospitals.append(h) + existing.add(h["id"]) + added += 1 + logger.info( + "OSM: added %d new hospitals (store total %d)", added, len(self.hospitals) + ) + return added + + def is_donor_region_seeded(self, lat: float, lng: float) -> bool: + return self._region_key(lat, lng) in self._donor_regions + + def mark_donor_region_seeded(self, lat: float, lng: float) -> None: + self._donor_regions.add(self._region_key(lat, lng)) + + def bulk_add_donors(self, donors: List[Dict]) -> int: + existing = {d["id"] for d in self.donors} + added = 0 + for d in donors: + if d["id"] not in existing: + self.donors.append(d) + existing.add(d["id"]) + added += 1 + logger.info( + "Donors: added %d demo donors near venue (store total %d)", + added, + len(self.donors), + ) + return added + + # --- Hospitals --------------------------------------------------------- + def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]: + out = [] + for h in self.hospitals: + enriched = dict(h) + enriched["distance_km"] = round( + haversine_km(lat, lng, h["lat"], h["lng"]), 2 + ) + out.append(enriched) + return out + + # --- Donors ------------------------------------------------------------ + def compatible_donors_nearby( + self, + lat: float, + lng: float, + compatible_groups: List[str], + radius_meters: float, + cooldown_days: int, + cooldown_days_female: Optional[int] = None, + today: Optional[date] = None, + ) -> List[Dict]: + today = today or date.today() + groups = set(compatible_groups) + result: List[Dict] = [] + for d in self.donors: + if d["blood_group"] not in groups: + continue + if not d.get("available", True): + continue + last = _parse_date(d.get("last_donated")) + cooldown = cooldown_days + if cooldown_days_female is not None and d.get("sex") == "female": + cooldown = cooldown_days_female + if last is not None and (today - last).days < cooldown: + continue + dist = haversine_meters(lat, lng, d["lat"], d["lng"]) + if dist > radius_meters: + continue + enriched = dict(d) + enriched["distance_m"] = round(dist, 1) + result.append(enriched) + result.sort(key=lambda d: d["distance_m"]) + return result + + def add_donor( + self, name, phone, blood_group, lat, lng, last_donated, sex=None + ) -> str: + donor_id = f"d{next(self._donor_seq)}" + self.donors.append( + { + "id": donor_id, + "name": name, + "phone": phone, + "blood_group": blood_group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "sex": sex, + "available": True, + } + ) + return donor_id + + # --- Emergencies ------------------------------------------------------- + def create_emergency( + self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted + ) -> Dict: + request_id = f"req{next(self._emergency_seq):04d}" + record = { + "id": request_id, + "lat": lat, + "lng": lng, + "emergency_type": emergency_type, + "blood_group": blood_group, + "status": "pending", + "created_at": _now(), + "hospitals": hospital_cards, + "donors_alerted": donors_alerted, + "donors_responded": 0, + } + self.emergencies[request_id] = record + return record + + def get_emergency(self, request_id: str) -> Optional[Dict]: + return self.emergencies.get(request_id) + + # --- Confirmation requests -------------------------------------------- + def create_confirmation( + self, emergency_id, hospital_id, hospital_name, token + ) -> Dict: + record = { + "token": token, + "emergency_id": emergency_id, + "hospital_id": hospital_id, + "hospital_name": hospital_name, + "sent_at": _now(), + "replied_at": None, + "confirmed": None, + } + self.confirmations[token] = record + return record + + def get_confirmation(self, token: str) -> Optional[Dict]: + return self.confirmations.get(token) + + def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]: + return [ + c for c in self.confirmations.values() if c["emergency_id"] == emergency_id + ] + + def emergency_is_taken(self, emergency_id: str) -> bool: + return any( + c["confirmed"] is True + for c in self.confirmations_for_emergency(emergency_id) + ) + + def hospital_reliability(self, hospital_id: str) -> tuple[int, float]: + """(#replied confirmations, acceptance rate) for a hospital, all-time. + + Powers the cold-start gate in ranking: reliability is trusted only once + enough confirmations have actually been logged for this hospital. + """ + replied = [ + c + for c in self.confirmations.values() + if c["hospital_id"] == hospital_id and c["confirmed"] is not None + ] + if not replied: + return 0, 0.0 + accepted = sum(1 for c in replied if c["confirmed"] is True) + return len(replied), accepted / len(replied) + + def record_reply(self, token: str, accepted: bool) -> None: + """Persist a hospital's Accept/Decline and update the emergency snapshot.""" + conf = self.confirmations.get(token) + if conf is None: + return + conf["confirmed"] = accepted + conf["replied_at"] = _now() + emergency = self.emergencies.get(conf["emergency_id"]) + if emergency is not None: + status = "confirmed" if accepted else "declined" + for card in emergency["hospitals"]: + if card["hospital_id"] == conf["hospital_id"]: + card["status"] = status + if accepted: + emergency["status"] = "confirmed" + + def ping(self) -> bool: + """Readiness check — the in-memory store is always ready.""" + return True + + +# =========================================================================== +# Supabase store (production) +# =========================================================================== +class SupabaseStore: + backend = "supabase" + + def __init__(self, client) -> None: + self.client = client + self._hospitals_cache: Optional[List[Dict]] = None + + # --- Hospitals --------------------------------------------------------- + @property + def hospitals(self) -> List[Dict]: + if self._hospitals_cache is None: + res = self.client.table("hospitals").select("*").execute() + self._hospitals_cache = res.data or [] + return self._hospitals_cache + + def hospitals_with_distance(self, lat: float, lng: float) -> List[Dict]: + out = [] + for h in self.hospitals: + enriched = dict(h) + enriched["distance_km"] = round( + haversine_km(lat, lng, h["lat"], h["lng"]), 2 + ) + out.append(enriched) + return out + + # --- Donors (PostGIS radius via RPC) ----------------------------------- + def compatible_donors_nearby( + self, + lat: float, + lng: float, + compatible_groups: List[str], + radius_meters: float, + cooldown_days: int, + cooldown_days_female: Optional[int] = None, + today: Optional[date] = None, + ) -> List[Dict]: + res = self.client.rpc( + "donors_nearby", + { + "p_lat": lat, + "p_lng": lng, + "p_groups": compatible_groups, + "p_radius_m": radius_meters, + "p_cooldown_days": cooldown_days, + "p_cooldown_days_female": ( + cooldown_days_female + if cooldown_days_female is not None + else cooldown_days + ), + }, + ).execute() + donors = res.data or [] + for d in donors: + d["distance_m"] = round(haversine_meters(lat, lng, d["lat"], d["lng"]), 1) + return donors + + def add_donor( + self, name, phone, blood_group, lat, lng, last_donated, sex=None + ) -> str: + donor_id = f"d{uuid.uuid4().hex[:8]}" + self.client.table("blood_donors").insert( + { + "id": donor_id, + "name": name, + "phone": phone, + "blood_group": blood_group, + "lat": lat, + "lng": lng, + "last_donated": last_donated, + "sex": sex, + "available": True, + } + ).execute() + return donor_id + + # --- Emergencies ------------------------------------------------------- + def create_emergency( + self, lat, lng, emergency_type, blood_group, hospital_cards, donors_alerted + ) -> Dict: + request_id = f"req{uuid.uuid4().hex[:8]}" + created_at = _now() + self.client.table("emergency_requests").insert( + { + "id": request_id, + "emergency_type": emergency_type, + "blood_group_needed": blood_group, + "lat": lat, + "lng": lng, + "status": "pending", + "donors_alerted": donors_alerted, + "donors_responded": 0, + "hospitals": hospital_cards, + } + ).execute() + return { + "id": request_id, + "lat": lat, + "lng": lng, + "emergency_type": emergency_type, + "blood_group": blood_group, + "status": "pending", + "created_at": created_at, + "hospitals": hospital_cards, + "donors_alerted": donors_alerted, + "donors_responded": 0, + } + + def get_emergency(self, request_id: str) -> Optional[Dict]: + res = ( + self.client.table("emergency_requests") + .select("*") + .eq("id", request_id) + .limit(1) + .execute() + ) + if not res.data: + return None + row = res.data[0] + return { + "id": row["id"], + "lat": row["lat"], + "lng": row["lng"], + "emergency_type": row["emergency_type"], + "blood_group": row["blood_group_needed"], + "status": row["status"], + "created_at": _parse_dt(row.get("created_at")), + "hospitals": row.get("hospitals") or [], + "donors_alerted": row.get("donors_alerted", 0), + "donors_responded": row.get("donors_responded", 0), + } + + # --- Confirmation requests -------------------------------------------- + def create_confirmation( + self, emergency_id, hospital_id, hospital_name, token + ) -> Dict: + record = { + "token": token, + "emergency_id": emergency_id, + "hospital_id": hospital_id, + "hospital_name": hospital_name, + "confirmed": None, + } + self.client.table("confirmation_requests").insert(record).execute() + record["sent_at"] = _now() + record["replied_at"] = None + return record + + def get_confirmation(self, token: str) -> Optional[Dict]: + res = ( + self.client.table("confirmation_requests") + .select("*") + .eq("token", token) + .limit(1) + .execute() + ) + if not res.data: + return None + row = res.data[0] + return { + "token": row["token"], + "emergency_id": row["emergency_id"], + "hospital_id": row["hospital_id"], + "hospital_name": row.get("hospital_name", ""), + "sent_at": _parse_dt(row.get("sent_at")), + "replied_at": row.get("replied_at"), + "confirmed": row.get("confirmed"), + } + + def confirmations_for_emergency(self, emergency_id: str) -> List[Dict]: + res = ( + self.client.table("confirmation_requests") + .select("*") + .eq("emergency_id", emergency_id) + .execute() + ) + return res.data or [] + + def emergency_is_taken(self, emergency_id: str) -> bool: + res = ( + self.client.table("confirmation_requests") + .select("token") + .eq("emergency_id", emergency_id) + .eq("confirmed", True) + .limit(1) + .execute() + ) + return bool(res.data) + + def hospital_reliability(self, hospital_id: str) -> tuple[int, float]: + """(#replied confirmations, acceptance rate) for a hospital, all-time.""" + res = ( + self.client.table("confirmation_requests") + .select("confirmed") + .eq("hospital_id", hospital_id) + .execute() + ) + replied = [r for r in (res.data or []) if r.get("confirmed") is not None] + if not replied: + return 0, 0.0 + accepted = sum(1 for r in replied if r["confirmed"] is True) + return len(replied), accepted / len(replied) + + def record_reply(self, token: str, accepted: bool) -> None: + self.client.table("confirmation_requests").update( + {"confirmed": accepted, "replied_at": _now().isoformat()} + ).eq("token", token).execute() + + conf = self.get_confirmation(token) + if conf is None: + return + emergency = self.get_emergency(conf["emergency_id"]) + if emergency is None: + return + status = "confirmed" if accepted else "declined" + cards = emergency["hospitals"] + for card in cards: + if card["hospital_id"] == conf["hospital_id"]: + card["status"] = status + update = {"hospitals": cards} + if accepted: + update["status"] = "confirmed" + self.client.table("emergency_requests").update(update).eq( + "id", conf["emergency_id"] + ).execute() + + def ping(self) -> bool: + """Readiness check — confirm the database answers a trivial query.""" + try: + self.client.table("hospitals").select("id").limit(1).execute() + return True + except Exception: + logger.exception("Supabase ping failed") + return False + + +# =========================================================================== +# Store selection +# =========================================================================== +_store = None + + +def get_store(): + """Process-wide singleton store, chosen from configuration. + + Supabase when configured and reachable; in-memory otherwise. A Supabase + init failure logs and falls back to in-memory rather than breaking boot. + """ + global _store + if _store is not None: + return _store + + if settings.use_supabase: + try: + from supabase import create_client + + client = create_client(settings.supabase_url, settings.supabase_active_key) + _store = SupabaseStore(client) + key_kind = "service-role" if settings.supabase_service_key else "anon" + logger.info("Data layer: Supabase (PostGIS), %s key", key_kind) + except Exception: + logger.exception("Supabase init failed — falling back to in-memory store") + _store = InMemoryStore() + else: + _store = InMemoryStore() + return _store diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..ef37057 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,27 @@ +"""Shared pytest fixtures. + +Each test gets a fresh, isolated in-memory store so order never matters. +""" + +import pytest +from fastapi.testclient import TestClient + +import store as store_module +from main import app + + +@pytest.fixture(autouse=True) +def fresh_store(): + """Reset the singleton store before every test.""" + store_module._store = None + yield store_module.get_store() + store_module._store = None + + +@pytest.fixture +def client(): + return TestClient(app) + + +# Jaipur centre — a convenient default emergency location. +JAIPUR = {"lat": 26.9124, "lng": 75.7873} diff --git a/backend/tests/fake_supabase.py b/backend/tests/fake_supabase.py new file mode 100644 index 0000000..cd2322b --- /dev/null +++ b/backend/tests/fake_supabase.py @@ -0,0 +1,136 @@ +"""A minimal in-process fake of the supabase-py client. + +Implements just the query-builder surface that ``SupabaseStore`` uses +(``table().select().eq().limit().execute()``, ``insert``, ``update``, and +``rpc``) backed by plain lists. Lets us exercise the production store's logic — +column names, query chains, key mapping — without a live database. +""" + +from __future__ import annotations + +from datetime import date +from typing import Dict, List + +import seed_data +from geo import haversine_meters + + +class _Result: + def __init__(self, data): + self.data = data + + +class _Query: + def __init__(self, rows: List[Dict], op: str, payload=None): + self._rows = rows # the live backing list for this table + self._op = op + self._payload = payload + self._filters = [] # list of (col, value) + self._limit = None + + # builder methods (all return self) -------------------------------------- + def select(self, *_cols): + self._op = "select" + return self + + def eq(self, col, value): + self._filters.append((col, value)) + return self + + def limit(self, n): + self._limit = n + return self + + def _matches(self, row) -> bool: + return all(row.get(col) == value for col, value in self._filters) + + # terminal --------------------------------------------------------------- + def execute(self) -> _Result: + if self._op == "select": + matched = [dict(r) for r in self._rows if self._matches(r)] + if self._limit is not None: + matched = matched[: self._limit] + return _Result(matched) + + if self._op == "insert": + items = ( + self._payload if isinstance(self._payload, list) else [self._payload] + ) + for item in items: + self._rows.append(dict(item)) + return _Result([dict(i) for i in items]) + + if self._op == "update": + updated = [] + for row in self._rows: + if self._matches(row): + row.update(self._payload) + updated.append(dict(row)) + return _Result(updated) + + return _Result([]) + + +class FakeSupabaseClient: + def __init__(self): + self.tables: Dict[str, List[Dict]] = { + "hospitals": [dict(h) for h in seed_data.hospitals()], + "blood_donors": [dict(d) for d in seed_data.donors()], + "emergency_requests": [], + "confirmation_requests": [], + } + + def table(self, name: str) -> _Query: + return _Query(self.tables.setdefault(name, []), op="select") + + # supabase: client.table(name).insert(...) / .update(...) + # The builder above starts in "select"; insert/update switch the op. + def _wrap_insert_update(self): # pragma: no cover - documentation only + ... + + def rpc(self, name: str, params: Dict) -> _Query: + if name != "donors_nearby": + return _Query([], op="select") + rows = self._donors_nearby(params) + q = _Query(rows, op="select") + return q + + def _donors_nearby(self, p: Dict) -> List[Dict]: + today = date.today() + groups = set(p["p_groups"]) + cd_male = p.get("p_cooldown_days", 90) + cd_female = p.get("p_cooldown_days_female", cd_male) + out = [] + for d in self.tables["blood_donors"]: + if d["blood_group"] not in groups or not d.get("available", True): + continue + last = d.get("last_donated") + if last: + cooldown = cd_female if d.get("sex") == "female" else cd_male + if (today - date.fromisoformat(last)).days < cooldown: + continue + if ( + haversine_meters(p["p_lat"], p["p_lng"], d["lat"], d["lng"]) + > p["p_radius_m"] + ): + continue + out.append(dict(d)) + return out + + +# supabase-py exposes insert()/update() on the table builder; add them here so +# `client.table(x).insert(...)` works with our _Query starting state. +def _insert(self, payload): + self._op = "insert" + self._payload = payload + return self + + +def _update(self, payload): + self._op = "update" + self._payload = payload + return self + + +_Query.insert = _insert +_Query.update = _update diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..6d6f0ab --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,215 @@ +"""Endpoint tests — verify the API_CONTRACT.md shapes and the live flow.""" + +from tests.conftest import JAIPUR + +import store as store_module + + +def _trigger(client, **overrides): + payload = {**JAIPUR, "emergency_type": "trauma", "blood_group": "O+", **overrides} + return client.post("/emergency", json=payload) + + +# --- Health / meta --------------------------------------------------------- +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert "version" in body and "mode" in body + + +def test_ready(client): + resp = client.get("/ready") + assert resp.status_code == 200 + body = resp.json() + assert body["ready"] is True + assert body["backend"] == "in-memory" + + +# --- POST /emergency ------------------------------------------------------- +def test_emergency_returns_contract_shape(client): + resp = _trigger(client) + assert resp.status_code == 200 + body = resp.json() + assert set(body) == {"request_id", "hospitals", "donors_alerted", "rare_group"} + assert isinstance(body["hospitals"], list) and body["hospitals"] + assert isinstance(body["donors_alerted"], int) + assert isinstance(body["rare_group"], bool) + + card = body["hospitals"][0] + assert set(card) == { + "hospital_id", + "name", + "eta_minutes", + "department_match", + "distance_km", + "status", + "phone", + } + assert card["status"] == "pending" + + +def test_emergency_alerts_donors_with_blood_bank_directive(client): + from services import sms_service + + sms_service.recent_alerts.clear() + _trigger(client) + assert sms_service.recent_alerts, "expected the blood branch to dispatch alerts" + assert any("blood bank" in a["message"].lower() for a in sms_service.recent_alerts) + + +def test_rare_group_flag_tracks_rh_negative(client): + assert _trigger(client, blood_group="O-").json()["rare_group"] is True + assert _trigger(client, blood_group="O+").json()["rare_group"] is False + + +def test_emergency_hospitals_sorted_best_first(client): + body = _trigger(client).json() + etas = [h["eta_minutes"] for h in body["hospitals"]] + # Not strictly sorted by ETA (score blends factors), but the best card + # should be a sensible, nearby option. + assert all(isinstance(e, int) and e >= 1 for e in etas) + + +def test_emergency_rejects_bad_enum(client): + resp = _trigger(client, emergency_type="zombie") + assert resp.status_code == 422 + + +def test_emergency_rejects_out_of_range_coordinates(client): + assert _trigger(client, lat=999).status_code == 422 + assert _trigger(client, lng=-500).status_code == 422 + + +# --- GET /emergency/{id}/status ------------------------------------------- +def test_status_unknown_request_id_is_404(client): + assert client.get("/emergency/nope/status").status_code == 404 + + +def test_status_shape(client): + request_id = _trigger(client).json()["request_id"] + body = client.get(f"/emergency/{request_id}/status").json() + assert set(body) == { + "request_id", + "hospitals", + "donors_alerted", + "donors_responded", + "unconfirmed_fallback", + } + assert body["request_id"] == request_id + # Freshly created — well inside the no-confirmation window. + assert body["unconfirmed_fallback"] is False + card = body["hospitals"][0] + assert set(card) == {"hospital_id", "name", "eta_minutes", "status"} + + +# --- POST /confirm/{token} — the live flow -------------------------------- +def test_confirm_flips_hospital_status_live(client): + request_id = _trigger(client).json()["request_id"] + store = store_module.get_store() + confs = store.confirmations_for_emergency(request_id) + assert len(confs) >= 2 + first, second = confs[0], confs[1] + + # First hospital accepts -> ok, not already confirmed. + r1 = client.post(f"/confirm/{first['token']}", json={"accepted": True}) + assert r1.status_code == 200 + body1 = r1.json() + assert set(body1) == {"ok", "hospital_name", "already_confirmed"} + assert body1["ok"] is True + assert body1["already_confirmed"] is False + + # Status now shows that hospital confirmed. + status = client.get(f"/emergency/{request_id}/status").json() + confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"] + assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed) + + # A second hospital accepting is told the patient is already routed. + r2 = client.post(f"/confirm/{second['token']}", json={"accepted": True}) + assert r2.json()["already_confirmed"] is True + + +def test_confirm_unknown_token_is_404(client): + resp = client.post("/confirm/does-not-exist", json={"accepted": True}) + assert resp.status_code == 404 + + +# --- GET /confirm/{token} — details for the hospital page ------------------ +def test_confirm_details_shape_and_values(client): + request_id = _trigger(client, emergency_type="trauma", blood_group="O+").json()[ + "request_id" + ] + store = store_module.get_store() + conf = store.confirmations_for_emergency(request_id)[0] + + body = client.get(f"/confirm/{conf['token']}").json() + assert set(body) == { + "hospital_name", + "emergency_type", + "blood_group", + "eta_minutes", + "already_confirmed", + "responded", + "accepted", + } + assert body["emergency_type"] == "trauma" + assert body["blood_group"] == "O+" + assert body["hospital_name"] == conf["hospital_name"] + assert isinstance(body["eta_minutes"], int) + # Freshly created, nobody has replied yet. + assert body["already_confirmed"] is False + assert body["responded"] is False + assert body["accepted"] is False + + +def test_confirm_details_reflects_reply_and_race(client): + request_id = _trigger(client).json()["request_id"] + store = store_module.get_store() + confs = store.confirmations_for_emergency(request_id) + first, second = confs[0], confs[1] + + client.post(f"/confirm/{first['token']}", json={"accepted": True}) + + # The hospital that accepted sees responded/accepted true. + winner = client.get(f"/confirm/{first['token']}").json() + assert winner["responded"] is True + assert winner["accepted"] is True + + # Another hospital sees the patient is already routed. + other = client.get(f"/confirm/{second['token']}").json() + assert other["already_confirmed"] is True + assert other["responded"] is False + + +def test_confirm_details_unknown_token_is_404(client): + assert client.get("/confirm/does-not-exist").status_code == 404 + + +# --- POST /donor/register -------------------------------------------------- +def test_register_donor(client): + resp = client.post( + "/donor/register", + json={ + "name": "Asha Verma", + "phone": "+919812345678", + "blood_group": "B+", + "lat": 26.92, + "lng": 75.80, + "last_donated": "2025-01-15", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["donor_id"].startswith("d") + + +# --- POST /sms/inbound ----------------------------------------------------- +def test_sms_inbound_parses_and_replies(client): + resp = client.post( + "/sms/inbound", + json={"from": "+919800000000", "body": "BLOOD O+ Malviya Nagar Jaipur"}, + ) + assert resp.status_code == 200 + assert "Nearest hospitals" in resp.json()["reply"] diff --git a/backend/tests/test_supabase_store.py b/backend/tests/test_supabase_store.py new file mode 100644 index 0000000..0d5d7cc --- /dev/null +++ b/backend/tests/test_supabase_store.py @@ -0,0 +1,86 @@ +"""Verify the production SupabaseStore logic against a fake supabase client. + +Exercises the same emergency -> confirm -> status flow as the in-memory tests, +but through ``SupabaseStore`` query chains, proving column names and key mapping +(e.g. blood_group_needed, hospitals jsonb, hospital_name) are correct. +""" + +import asyncio + +from blood import compatible_donor_groups +from services import confirm_service, emergency_service +from store import SupabaseStore +from tests.fake_supabase import FakeSupabaseClient + + +def _store(): + return SupabaseStore(FakeSupabaseClient()) + + +def test_ping_ok(): + assert _store().ping() is True + + +def test_hospitals_with_distance_reads_table(): + store = _store() + rows = store.hospitals_with_distance(26.9124, 75.7873) + assert len(rows) == 25 + assert all("distance_km" in r for r in rows) + + +def test_compatible_donors_nearby_via_rpc(): + store = _store() + donors = store.compatible_donors_nearby( + 26.9124, + 75.7873, + compatible_donor_groups("O+"), + radius_meters=5000, + cooldown_days=90, + ) + compatible = set(compatible_donor_groups("O+")) + assert all(d["blood_group"] in compatible for d in donors) + assert all(d["distance_m"] <= 5000 for d in donors) + + +def test_full_flow_emergency_confirm_status(): + store = _store() + + resp = asyncio.run( + emergency_service.trigger_emergency( + store, lat=26.9124, lng=75.7873, emergency_type="trauma", blood_group="O+" + ) + ) + assert resp["hospitals"] and "request_id" in resp + request_id = resp["request_id"] + + # The emergency and its confirmations persisted to the fake DB. + confs = store.confirmations_for_emergency(request_id) + assert len(confs) == len(resp["hospitals"]) + + # First hospital accepts. + first = confs[0] + out = confirm_service.handle_confirmation(store, first["token"], accepted=True) + assert out["already_confirmed"] is False + assert out["hospital_name"] + + # Status reflects the confirmation, read back from the DB. + status = emergency_service.get_status(store, request_id) + confirmed = [h for h in status["hospitals"] if h["status"] == "confirmed"] + assert any(h["hospital_id"] == first["hospital_id"] for h in confirmed) + + # A second hospital accepting is told the patient is already routed. + if len(confs) > 1: + out2 = confirm_service.handle_confirmation( + store, confs[1]["token"], accepted=True + ) + assert out2["already_confirmed"] is True + + +def test_register_donor_persists(): + store = _store() + before = len(store.tables["blood_donors"]) if hasattr(store, "tables") else None + donor_id = store.add_donor("Test User", "+919800000000", "B+", 26.9, 75.8, None) + assert donor_id.startswith("d") + # Confirm it is queryable back. + rows = store.client.tables["blood_donors"] + assert any(r["id"] == donor_id for r in rows) diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py new file mode 100644 index 0000000..1e21e9f --- /dev/null +++ b/backend/tests/test_units.py @@ -0,0 +1,129 @@ +"""Unit tests for the algorithms: blood compatibility, geo, ranking, matching.""" + +import asyncio +from datetime import date, timedelta + +from blood import compatible_donor_groups +from geo import haversine_km +from services.donor_service import match_donors +from services.hospital_service import rank_hospitals +from store import get_store + + +# --- Blood compatibility --------------------------------------------------- +def test_o_negative_is_universal_donor_only_for_o_negative_recipient(): + assert compatible_donor_groups("O-") == ["O-"] + + +def test_ab_positive_is_universal_recipient(): + assert set(compatible_donor_groups("AB+")) == { + "O-", + "O+", + "A-", + "A+", + "B-", + "B+", + "AB-", + "AB+", + } + + +def test_a_positive_recipient_accepts_o_and_a(): + assert set(compatible_donor_groups("A+")) == {"O-", "O+", "A-", "A+"} + + +def test_unknown_group_fails_safe_to_empty(): + assert compatible_donor_groups("Z+") == [] + + +# --- Geo ------------------------------------------------------------------- +def test_haversine_zero_distance(): + assert haversine_km(26.9, 75.8, 26.9, 75.8) == 0.0 + + +def test_haversine_known_distance_is_reasonable(): + # ~1 degree of latitude is ~111 km. + km = haversine_km(26.0, 75.0, 27.0, 75.0) + assert 110 < km < 112 + + +# --- Hospital ranking ------------------------------------------------------ +def test_ranking_returns_at_most_top_n_sorted_by_score(): + store = get_store() + ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5)) + assert 1 <= len(ranked) <= 5 + scores = [c["_score"] for c in ranked] + assert scores == sorted(scores, reverse=True) + + +def test_ranking_prefers_department_match(): + store = get_store() + ranked = asyncio.run(rank_hospitals(store, 26.9124, 75.7873, "trauma", top_n=5)) + # At least one of the top hospitals actually has the trauma department. + assert any(c["department_match"] for c in ranked) + + +def test_hospital_reliability_cold_start_then_accumulates(): + # At launch a hospital has no logged confirmations -> rel is not trusted. + store = get_store() + assert store.hospital_reliability("h1") == (0, 0.0) + + # Two confirmations logged for the same hospital: one accept, one decline. + store.create_confirmation("e1", "h1", "SMS Hospital", "tok-a") + store.create_confirmation("e1", "h1", "SMS Hospital", "tok-b") + store.record_reply("tok-a", True) + store.record_reply("tok-b", False) + count, rate = store.hospital_reliability("h1") + assert count == 2 and rate == 0.5 + + +# --- Donor matching -------------------------------------------------------- +def test_match_donors_respects_compatibility_and_radius(): + store = get_store() + today = date(2026, 6, 18) + donors = match_donors(store, 26.9124, 75.7873, "O+", today=today) + compatible = set(compatible_donor_groups("O+")) + assert all(d["blood_group"] in compatible for d in donors) + assert all(d["distance_m"] <= 5000 for d in donors) + + +def test_match_donors_excludes_recent_donations(): + store = get_store() + today = date(2026, 6, 18) + donors = match_donors(store, 26.9124, 75.7873, "AB+", today=today) + for d in donors: + if d["last_donated"]: + days = (today - date.fromisoformat(d["last_donated"])).days + assert days >= 90 + + +def test_female_donor_has_longer_cooldown(): + # Same group, same spot, donated 100 days ago: a man is eligible (>= 90d), + # a woman is still inside her 120-day window. + store = get_store() + today = date(2026, 6, 18) + hundred_days_ago = (today - timedelta(days=100)).isoformat() + store.add_donor( + "Male Donor", + "+919800000001", + "O-", + 26.9124, + 75.7873, + hundred_days_ago, + sex="male", + ) + store.add_donor( + "Female Donor", + "+919800000002", + "O-", + 26.9124, + 75.7873, + hundred_days_ago, + sex="female", + ) + + phones = { + d["phone"] for d in match_donors(store, 26.9124, 75.7873, "O-", today=today) + } + assert "+919800000001" in phones # man: 100 days >= 90-day cooldown + assert "+919800000002" not in phones # woman: 100 days < 120-day cooldown diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3eaf7ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# One-command local backend: `docker compose up`. +# The API runs in demo mode by default (in-memory store, no external services). +# To point it at a real Supabase/Maps stack, fill in backend/.env and it is +# picked up automatically. +services: + api: + build: ./backend + ports: + - "8000:8000" + env_file: + - path: ./backend/.env + required: false + environment: + # Sensible demo defaults even without a .env file present. + DELIVERY_CHANNEL: ${DELIVERY_CHANNEL:-console} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + restart: unless-stopped diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..2f7155d --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + Backend architecture — three layers, two interchangeable stores + + + + HTTP routes · main.py + validate (schemas.py) → call exactly one service → return a schema + + + + + + Service layer · services/ + ranking · matching · confirmation · ETA · SMS/alerts · realtime + + + + + + Data layer · store.py + get_store() picks one → + + + + InMemoryStore + demo / CI · seeded 25 hospitals + + 180 donors · haversine + + + SupabaseStore + PostgreSQL + PostGIS · donors_nearby + RPC · Realtime · production + + + + External (prod only) + Google Maps Distance + Matrix — real drive ETA + MSG91 / Gupshup + DLT + — production SMS + Supabase Realtime + — live status push + + Absent these vars, the API + runs fully in demo mode + — zero external services, + CI needs no database. + + + + API_CONTRACT.md — the shared request/response shapes both sides build against + diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..3ca9019 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GoldenHour + One request → the right hospital and ready blood, + inside the hour that decides. + + + + + + Healthcare Tech + + + + Social Impact + + + + FastAPI + + + + Supabase + + + + PostGIS + + + diff --git a/docs/flow.svg b/docs/flow.svg new file mode 100644 index 0000000..4406991 --- /dev/null +++ b/docs/flow.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + One request → two parallel actions + + + + + POST /emergency + GPS lat/lng · emergency type · blood group + + + + + + + + + + Rank hospitals + proximity · department · reliability (gated) + + + + Send 1-tap confirm link to each + + + + First to Accept = routed hospital + + + + + + Match donors + ABO/Rh · within 5 km · sex-aware cooldown + + + + Alert nearest top-K donors + + + + → Licensed blood bank + not the emergency ward + + + + + Patient screen updates live — Supabase Realtime / polling + diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8209332 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +# GoldenHour Frontend Environment Variables +VITE_API_BASE_URL=http://localhost:8000 +VITE_SUPABASE_URL=https://your-supabase-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..1eb4846 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,8 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environments +.env +.env.* +!.env.example diff --git a/frontend/README.md b/frontend/README.md index a36934d..f580ff1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,108 @@ -# React + Vite +# GoldenHour — Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The mobile-first PWA that everything the user touches lives in: a panicked +family member triggers an emergency, watches hospitals respond live, and a +hospital contact accepts the patient — all on a phone screen. -Currently, two official plugins are available: +Built with **React + Vite + Tailwind CSS** and **React Router**. Every API call +matches [`../API_CONTRACT.md`](../API_CONTRACT.md) exactly. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +--- -## React Compiler +## Tech stack -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| Piece | Tool | Why | +| --- | --- | --- | +| Framework | React 19 + Vite | Fast dev server, instant HMR | +| Styling | Tailwind CSS | Clean, mobile-friendly utility classes | +| Routing | React Router | Switch between the four screens | +| Live updates | Supabase JS client / polling | Cards flip live when a hospital confirms | -## Expanding the ESLint configuration +--- -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +## The four screens + +Each is a route rendered inside a shared mobile shell (`src/App.jsx`): + +| Route | Component | What it does | +| --- | --- | --- | +| `/` | `PatientIntakeView` | Emergency intake — "Use my location" (Geolocation API), emergency type + blood group dropdowns, big **GET HELP** button → `POST /emergency`, navigates to `/results/:id`. | +| `/results/:id` | `PatientResultsView` | Live results — list of hospital cards (name, ETA badge, department match, status badge `pending`/`confirmed`/`declined`, 1-tap call button) and a blood-donor panel. Cards update live. | +| `/confirm/:token` | `HospitalConfirmPage` | The hospital contact's view — emergency type · blood group · ETA, **Accept Patient** / **Not Available** → `POST /confirm/{token}`. Shows "already routed" when `already_confirmed` is true. | +| `/register` | `DonorRegistration` | One-screen donor sign-up form → `POST /donor/register`. | + +> **Status:** screens are built and navigable; they currently render placeholder +> data per the "fake data first" approach in the PRD. Wiring the real `fetch()` +> calls against the backend is the remaining integration step — because each +> screen is built against the contract shapes, swapping fake data for `fetch()` +> is a localized change. + +### Live updates + +When a hospital taps **Accept** on `/confirm/:token`, the matching card on +`/results/:id` must flip `pending → confirmed` without a refresh. Two options +(agree with the backend owner which to ship): + +- **Polling** — call `GET /emergency/{id}/status` every ~3 s and re-render. +- **Supabase Realtime** — subscribe to the `confirmation_requests` table and + update the matching card when a row changes. + +Start with polling; upgrade to Realtime if there's time. + +--- + +## Repository structure + +``` +frontend/ +├── index.html # App shell + meta/description/OG/PWA tags +├── src/ +│ ├── main.jsx # React entrypoint +│ ├── App.jsx # Mobile shell + React Router routes +│ ├── components/ +│ │ ├── PatientIntakeView.jsx # Screen 1 (/) +│ │ ├── PatientResultsView.jsx # Screen 2 (/results/:id) +│ │ ├── HospitalConfirmPage.jsx # Screen 4 (/confirm/:token) +│ │ └── DonorRegistration.jsx # Screen 3 (/register) +│ ├── index.css # Tailwind entry +│ └── assets/ # Images / icons +├── public/ # favicon, static icons +├── tailwind.config.js +├── vite.config.js +└── package.json +``` + +--- + +## Running it + +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Other scripts: `npm run build` (production build → `dist/`), `npm run preview` +(serve the build), `npm run lint`. + +### Talking to the backend + +The API base URL is supplied via a Vite env var. Create `frontend/.env.local`: + +``` +VITE_API_BASE_URL=http://localhost:8000 +``` + +In production set it to the deployed backend, e.g. +`https://goldenhour-api.onrender.com`. Read it in code with +`import.meta.env.VITE_API_BASE_URL`. (The backend has CORS open for the +frontend origin, so cross-port calls in dev just work.) + +--- + +## Deployment + +Deployed on Vercel — see [`../vercel.json`](../vercel.json). It builds +`frontend/` and rewrites all routes to `index.html` so React Router's client-side +routes (`/results/:id`, `/confirm/:token`, …) resolve on hard refresh and +deep links. Set `VITE_API_BASE_URL` as a Vercel environment variable. diff --git a/frontend/index.html b/frontend/index.html index f94d687..0794bb4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,52 @@ - - frontend + + + + + + GoldenHour — Every Second Counts + + + + + + + + + + + + + + + + + + + + + + + + - +
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49a1301..acb10ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,19 +1,27 @@ { - "name": "frontend", - "version": "0.0.0", + "name": "goldenhour-frontend", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", - "version": "0.0.0", + "name": "goldenhour-frontend", + "version": "0.1.0", + "license": "MIT", "dependencies": { + "@supabase/supabase-js": "^2.108.2", + "@types/three": "^0.184.1", + "framer-motion": "^12.40.0", + "gsap": "^3.15.0", + "lenis": "^1.3.23", + "lucide-react": "^1.21.0", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "three": "^0.184.0" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@supabase/supabase-js": "^2.108.2", + "@tailwindcss/vite": "^4.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -23,7 +31,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "postcss": "^8.5.15", - "react-router-dom": "^7.18.0", + "react-router-dom": "^6.30.4", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } @@ -269,10 +277,16 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -553,6 +567,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -776,6 +800,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -821,7 +879,6 @@ "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -834,7 +891,6 @@ "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -847,14 +903,12 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", - "dev": true, "license": "MIT" }, "node_modules/@supabase/postgrest-js": { "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -867,7 +921,6 @@ "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", - "dev": true, "license": "MIT", "dependencies": { "@supabase/phoenix": "^0.4.2", @@ -881,7 +934,6 @@ "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", - "dev": true, "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -895,7 +947,6 @@ "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", - "dev": true, "license": "MIT", "dependencies": { "@supabase/auth-js": "2.108.2", @@ -908,6 +959,284 @@ "node": ">=20.0.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -961,6 +1290,32 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -1164,20 +1519,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1242,6 +1583,20 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1490,6 +1845,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1555,6 +1916,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1606,6 +1994,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1627,7 +2028,6 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.0.0" @@ -1683,6 +2083,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1747,6 +2157,37 @@ "json-buffer": "3.0.1" } }, + "node_modules/lenis": { + "version": "1.3.23", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz", + "integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2048,6 +2489,31 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2064,6 +2530,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2279,43 +2760,37 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "dev": true, "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "@remix-run/router": "1.23.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/rolldown": { @@ -2368,13 +2843,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2415,6 +2883,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2436,7 +2924,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/frontend/package.json b/frontend/package.json index cff662a..95a1508 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,8 +1,20 @@ { - "name": "frontend", + "name": "goldenhour-frontend", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", + "description": "GoldenHour PWA — patient emergency intake, live hospital results, donor registration, and the hospital confirmation page. React + Vite + Tailwind.", + "keywords": [ + "goldenhour", + "emergency", + "healthcare", + "blood-donor", + "pwa", + "react", + "vite" + ], + "author": "Team GoldenHour", + "license": "MIT", "scripts": { "dev": "vite", "build": "vite build", @@ -10,12 +22,19 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.108.2", + "@types/three": "^0.184.1", + "framer-motion": "^12.40.0", + "gsap": "^3.15.0", + "lenis": "^1.3.23", + "lucide-react": "^1.21.0", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "three": "^0.184.0" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@supabase/supabase-js": "^2.108.2", + "@tailwindcss/vite": "^4.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -25,7 +44,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "postcss": "^8.5.15", - "react-router-dom": "^7.18.0", + "react-router-dom": "^6.30.4", "tailwindcss": "^4.3.1", "vite": "^8.0.12" } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..3a7c55d Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..d61b655 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..ee7b6f7 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,26 @@ +{ + "name": "GoldenHour — Emergency Hospital & Blood Response", + "short_name": "GoldenHour", + "description": "One GPS tap finds a hospital with the right department and a free bed, and alerts nearby compatible blood donors.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#FBFAF8", + "theme_color": "#DC2626", + "categories": ["health", "medical"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..21a4881 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,35 @@ +// GoldenHour service worker — minimal offline app shell. +// Bump CACHE when the shell changes to evict the old one on activate. +const CACHE = 'goldenhour-v1'; +const SHELL = ['/', '/index.html', '/favicon.svg', '/icon-192.png', '/icon-512.png']; + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + // Never touch non-GET (API writes) or cross-origin requests (backend API, fonts). + if (request.method !== 'GET') return; + if (new URL(request.url).origin !== self.location.origin) return; + + // Navigations: network-first so a fresh deploy is picked up; fall back to the + // cached shell when offline. + if (request.mode === 'navigate') { + event.respondWith(fetch(request).catch(() => caches.match('/index.html'))); + return; + } + + // Same-origin static assets (content-hashed by Vite): cache-first. + event.respondWith(caches.match(request).then((hit) => hit || fetch(request))); +}); diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..0c8e5b8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,19 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); +/* App-specific custom animations or styles */ +@keyframes pulse-ring { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(220, 38, 38, 0); } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); } } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +.animate-emergency-pulse { + animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 348dc6a..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import PatientIntakeView from './components/PatientIntakeView'; -import PatientResultsView from './components/PatientResultsView'; -import HospitalConfirmPage from './components/HospitalConfirmPage'; -import DonorRegistration from './components/DonorRegistration'; - -function App() { - return ( - -
-
- GoldenHour -
-
- - } /> - } /> - } /> - } /> - -
-
-
- ); -} - -export default App; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3279a29 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { AppBar } from './components/ui/AppBar'; +import { CustomCursor } from './components/ui/CustomCursor'; +import PatientIntakeView from './components/PatientIntakeView'; +import PatientResultsView from './components/PatientResultsView'; +import HospitalConfirmPage from './components/HospitalConfirmPage'; +import DonorRegistration from './components/DonorRegistration'; + +// Shared premium page entry animation wrapper +const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ); +}; + +function AppContent() { + const location = useLocation(); + const isHome = location.pathname === '/'; + const isHospitalConfirm = location.pathname.startsWith('/confirm/'); + + // Dark theme for home, light for sub-screens + const theme = isHome ? 'dark' : 'light'; + + return ( +
+ + {/* GoldenHour sunset gradient branding line */} + {!isHome && ( +
+ )} + + {!isHospitalConfirm && } + +
+ + + : } /> + } /> + } /> + } /> + + +
+
+ ); +} + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/frontend/src/components/DonorRegistration.jsx b/frontend/src/components/DonorRegistration.jsx deleted file mode 100644 index b41b340..0000000 --- a/frontend/src/components/DonorRegistration.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useState } from 'react'; - -function DonorRegistration() { - const [registered, setRegistered] = useState(false); - - if (registered) { - return ( -
-
✓
-

Registration Complete

-

Thank you for signing up to save a life.

-
- ); - } - - return ( -
-
-

Become a Donor

-

Register to be alerted when replacement blood is needed nearby.

-
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - - - -
-
- ); -} - -export default DonorRegistration; diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx new file mode 100644 index 0000000..f1c7567 --- /dev/null +++ b/frontend/src/components/DonorRegistration.tsx @@ -0,0 +1,360 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Input } from './ui/Input'; +import { Select } from './ui/Select'; +import { Button } from './ui/Button'; +import { api } from '../lib/api'; + +export default function DonorRegistration() { + const navigate = useNavigate(); + + // Form Fields State + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [bloodGroup, setBloodGroup] = useState(''); + const [lastDonated, setLastDonated] = useState(''); + + // Geolocation State + const [locating, setLocating] = useState(false); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); + const [locationError, setLocationError] = useState(null); + + // Form Submission & Success states + const [errors, setErrors] = useState<{ name?: string; phone?: string; blood?: string }>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRegistered, setIsRegistered] = useState(false); + const [registeredId, setRegisteredId] = useState(''); + + // GPS Locator handler + const handleGPSFetch = () => { + if (!navigator.geolocation) { + setLocationError('Location services not supported.'); + return; + } + + setLocating(true); + setLocationError(null); + + navigator.geolocation.getCurrentPosition( + (position) => { + setCoords({ + lat: parseFloat(position.coords.latitude.toFixed(6)), + lng: parseFloat(position.coords.longitude.toFixed(6)) + }); + setLocating(false); + }, + (error) => { + console.error('GPS registration fetch error', error); + let errorMsg = 'Failed to locate. Try again.'; + if (error.code === error.PERMISSION_DENIED) { + errorMsg = 'Location permission blocked.'; + } + setLocationError(errorMsg); + setLocating(false); + }, + { timeout: 8000, enableHighAccuracy: true } + ); + }; + + // Basic Validation + const validateForm = () => { + const newErrors: typeof errors = {}; + if (!name.trim()) newErrors.name = 'Full name is required'; + + // Check 10-digit phone format roughly + const cleanPhone = phone.replace(/[^0-9+]/g, ''); + if (!phone) { + newErrors.phone = 'Phone number is required'; + } else if (cleanPhone.length < 8) { + newErrors.phone = 'Please enter a valid phone number'; + } + + if (!bloodGroup) newErrors.blood = 'Blood type selection is required'; + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Submit Handler + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + + try { + const data = await api.registerDonor( + name, + phone, + bloodGroup, + coords?.lat ?? null, + coords?.lng ?? null, + lastDonated || null + ); + + if (data && data.ok) { + setRegisteredId(data.donor_id || 'd42'); + setIsRegistered(true); + } else { + simulateRegistrationSuccess(); + } + } catch (err) { + console.warn('Backend server offline. Simulating local registration success.', err); + simulateRegistrationSuccess(); + } finally { + setIsSubmitting(false); + } + }; + + const simulateRegistrationSuccess = () => { + const mockId = `d-${Math.random().toString(36).substring(2, 6).toUpperCase()}`; + setRegisteredId(mockId); + setIsRegistered(true); + }; + + const bloodOptions = [ + { value: '', label: '-- Select Blood Group --' }, + { value: 'O+', label: 'O+' }, + { value: 'O-', label: 'O-' }, + { value: 'A+', label: 'A+' }, + { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, + { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, + { value: 'AB-', label: 'AB-' } + ]; + + // Disable check: Name, Phone, and Blood Group must be populated + const isButtonDisabled = !name.trim() || !phone.trim() || !bloodGroup; + + return ( +
+ + {/* Visual Identity Highlight (GoldenHour crimson to amber stripe) */} +
+ + + {!isRegistered ? ( + + {/* Header / Hero */} +
+

+ Become a donor. Save a life. +

+

+ Register to receive alerts when emergency blood replacements are needed within 5km. +

+
+ +
+ {/* Name Input */} + setName(e.target.value)} + error={errors.name} + required + aria-required="true" + /> + + {/* Phone Input */} + setPhone(e.target.value)} + error={errors.phone} + required + aria-required="true" + /> + + {/* Blood Group Selector */} + setLastDonated(e.target.value)} + /> + + {/* Submit Registration Button */} + +
+
+ ) : ( + /* Premium Success State Panel */ + + {/* Animated Ring Checkmark */} +
+ +
+ ✓ +
+
+ +
+

You're registered 🎉

+

+ Thank you, {name}. We've logged your profile under Donor ID {registeredId}. +

+

+ We'll alert you immediately when someone nearby needs your blood type ({bloodGroup}). +

+
+ + +
+ )} +
+ +
+ ); +} diff --git a/frontend/src/components/HospitalConfirmPage.jsx b/frontend/src/components/HospitalConfirmPage.jsx deleted file mode 100644 index 87c6c68..0000000 --- a/frontend/src/components/HospitalConfirmPage.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; - -function HospitalConfirmPage() { - const { token } = useParams(); - const [responded, setResponded] = useState(false); - const [accepted, setAccepted] = useState(false); - - const handleResponse = (isAccepted) => { - // API call will go here - setAccepted(isAccepted); - setResponded(true); - }; - - if (responded) { - return ( -
-
✓
-

Thank you

-

The family has been notified.

-
- ); - } - - return ( -
-
-

🚨 Emergency Request

-
-

Type: Trauma

-

Blood: O+

-

ETA: 6 mins

-
-
- -
- - -
-
- ); -} - -export default HospitalConfirmPage; diff --git a/frontend/src/components/HospitalConfirmPage.tsx b/frontend/src/components/HospitalConfirmPage.tsx new file mode 100644 index 0000000..cb722dd --- /dev/null +++ b/frontend/src/components/HospitalConfirmPage.tsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Button } from './ui/Button'; +import { api } from '../lib/api'; + +export default function HospitalConfirmPage() { + // Read token from URL + const { token } = useParams<{ token: string }>(); + + // State Management + const [isResponded, setIsResponded] = useState(false); + const [accepted, setAccepted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hospitalName, setHospitalName] = useState('SMS Hospital'); + + // Dynamic emergency details state + const [emergencyType, setEmergencyType] = useState('Trauma'); + const [bloodGroup, setBloodGroup] = useState('O−'); + const [etaMinutes, setEtaMinutes] = useState(6); + const [isLoading, setIsLoading] = useState(true); + + // Interactive toggle to allow judges to test the "already routed" UI state + const [alreadyConfirmed, setAlreadyConfirmed] = useState(false); + + // Fetch confirmation request details on mount + useEffect(() => { + async function loadDetails() { + if (!token) { + setIsLoading(false); + return; + } + try { + const data = await api.getHospitalConfirmDetails(token); + if (data) { + setHospitalName(data.hospital_name); + setEmergencyType(data.emergency_type.charAt(0).toUpperCase() + data.emergency_type.slice(1)); + setBloodGroup(data.blood_group); + setEtaMinutes(data.eta_minutes); + setAlreadyConfirmed(data.already_confirmed); + if (data.responded) { + setAccepted(data.accepted); + setIsResponded(true); + } + } + } catch (err) { + console.warn('Backend server offline or invalid token. Using mock/simulation details for demo.', err); + } finally { + setIsLoading(false); + } + } + loadDetails(); + }, [token]); + + // Handle Dispatch Decisions + const handleResponse = async (isAccepted: boolean) => { + if (!token) return; + setIsSubmitting(true); + + try { + const data = await api.confirmHospitalRequest(token, isAccepted); + if (data && data.ok) { + setHospitalName(data.hospital_name || 'SMS Hospital'); + setAlreadyConfirmed(!!data.already_confirmed); + setAccepted(isAccepted); + setIsResponded(true); + } else { + simulateResponse(isAccepted); + } + } catch (e) { + console.warn('Backend server offline. Simulating response locally for demo.', e); + simulateResponse(isAccepted); + } finally { + setIsSubmitting(false); + } + }; + + const simulateResponse = (isAccepted: boolean) => { + setHospitalName('SMS Hospital'); + setAccepted(isAccepted); + setIsResponded(true); + }; + + return ( +
+ + {/* Compact Header: Brand + Status Label */} +
+
+ + + + + + GoldenHour + +
+ +
+ + + + Emergency Request +
+ {!isLoading && ( +

{hospitalName}

+ )} +
+ + + {isLoading ? ( + + +
+
+
+
+
+
+
+
+
+ + + ) : !isResponded ? ( + + {/* Clean summary card (Dynamic values from API contract) */} + +
+
+ + Emergency Type + + + {emergencyType} + +
+ +
+ + Blood Group Needed + + + {bloodGroup} + +
+ +
+ + Patient ETA + + + {etaMinutes} min away + +
+
+
+ + {/* Action panel conditional rendering based on alreadyConfirmed state */} + {alreadyConfirmed ? ( + +
+ ! +
+

Already Routed

+

+ This patient has already been routed to another hospital. No dispatch action is required. +

+
+ ) : ( +
+ {/* Accept Button */} + + + {/* Decline Button */} + +
+ )} + + {/* Simulated Toggle for Evaluators */} +
+ +
+
+ ) : ( + /* Response State Panel */ + + {accepted ? ( + +
+
+ ✓ +
+
+

Dispatch Secured

+

+ Thank you. The patient has been routed to {hospitalName}. The family has been notified. +

+
+
+
+ ) : ( + +
+
+ ✕ +
+
+

Declined

+

+ Acknowledgment received. The emergency will be routed to the next nearest responder. +

+
+
+
+ )} + + +
+ )} + + + {/* Secure Token Footer */} +
+

+ Secure token validation: {token} +

+
+
+ ); +} diff --git a/frontend/src/components/PatientIntakeView.jsx b/frontend/src/components/PatientIntakeView.jsx deleted file mode 100644 index a59b1ad..0000000 --- a/frontend/src/components/PatientIntakeView.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -function PatientIntakeView() { - const navigate = useNavigate(); - - return ( -
-

Emergency? Get help now.

- - {/* Fake location for now */} - - -
- - -
- -
- - -
- - -
- ); -} - -export default PatientIntakeView; diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx new file mode 100644 index 0000000..7cee75e --- /dev/null +++ b/frontend/src/components/PatientIntakeView.tsx @@ -0,0 +1,600 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import { gsap, ScrollTrigger } from '../lib/gsap-setup'; +import { HeroScene } from './three/HeroScene'; +import { TextReveal } from './motion/TextReveal'; +import { CardReveal } from './motion/CardReveal'; +import { CountUp } from './motion/CountUp'; +import { Card } from './ui/Card'; +import { Button } from './ui/Button'; +import { Select } from './ui/Select'; +import { api } from '../lib/api'; + +export default function PatientIntakeView() { + const navigate = useNavigate(); + + // Selections + const [emergencyType, setEmergencyType] = useState(''); + const [bloodGroup, setBloodGroup] = useState(''); + + // Geolocation State + const [locating, setLocating] = useState(false); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); + const [locationError, setLocationError] = useState(null); + + // Form submission states + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isSceneLoaded, setIsSceneLoaded] = useState(false); + + // Refs for GSAP animations + const heroTitleRef = useRef(null); + const heroSubRef = useRef(null); + const scrollHintRef = useRef(null); + const horizontalRef = useRef(null); + const horizontalInnerRef = useRef(null); + + // Trigger Geolocation API + const handleAcquireLocation = () => { + if (!navigator.geolocation) { + setLocationError('Your browser does not support location services.'); + return; + } + setLocating(true); + setLocationError(null); + navigator.geolocation.getCurrentPosition( + (position) => { + setCoords({ + lat: parseFloat(position.coords.latitude.toFixed(6)), + lng: parseFloat(position.coords.longitude.toFixed(6)) + }); + setLocating(false); + }, + (error) => { + let errorMsg = 'Failed to acquire location. Please try again.'; + if (error.code === error.PERMISSION_DENIED) { + errorMsg = 'Location permission denied. Please enable location access.'; + } + setLocationError(errorMsg); + setLocating(false); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }; + + const isFormValid = coords !== null && emergencyType !== '' && bloodGroup !== ''; + + const dispatchEmergency = async () => { + if (!isFormValid || !coords) return; + setIsSubmitting(true); + setSubmitError(null); + try { + const data = await api.triggerEmergency(coords.lat, coords.lng, emergencyType, bloodGroup); + if (data && data.request_id) { + navigate(`/results/${data.request_id}`); + } else { + throw new Error('Invalid request ID returned from server.'); + } + } catch (err: any) { + setSubmitError(err.message || 'Connection failed. Please verify that the API is online.'); + } finally { + setIsSubmitting(false); + } + }; + + const typeOptions = [ + { value: '', label: '-- Choose Emergency Type --' }, + { value: 'trauma', label: 'Trauma / Accident' }, + { value: 'cardiac', label: 'Cardiac / Heart' }, + { value: 'obstetric', label: 'Obstetric / Pregnancy' }, + { value: 'general', label: 'General Medical' } + ]; + + const bloodOptions = [ + { value: '', label: '-- Choose Blood Group --' }, + { value: 'O+', label: 'O+' }, { value: 'O-', label: 'O-' }, + { value: 'A+', label: 'A+' }, { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, { value: 'AB-', label: 'AB-' } + ]; + + // === GSAP ANIMATIONS === + + // Hero staggered title reveal + useEffect(() => { + const ctx = gsap.context(() => { + // Title letters stagger + if (heroTitleRef.current) { + const text = heroTitleRef.current.textContent || ''; + heroTitleRef.current.innerHTML = ''; + text.split('').forEach((char, i) => { + const span = document.createElement('span'); + span.textContent = char === ' ' ? '\u00A0' : char; + span.style.display = 'inline-block'; + span.style.opacity = '0'; + span.style.transform = 'translateY(100%)'; + heroTitleRef.current!.appendChild(span); + }); + + gsap.to(heroTitleRef.current.children, { + opacity: 1, + y: 0, + duration: 0.8, + stagger: 0.03, + ease: 'power3.out', + delay: 0.3, + }); + } + + // Subtitle fade in + if (heroSubRef.current) { + gsap.fromTo(heroSubRef.current, + { opacity: 0, y: 30 }, + { opacity: 1, y: 0, duration: 1.2, ease: 'power3.out', delay: 1.2 } + ); + } + + // Scroll hint pulse + if (scrollHintRef.current) { + gsap.fromTo(scrollHintRef.current, + { opacity: 0 }, + { opacity: 1, duration: 1, delay: 2 } + ); + } + }); + + return () => ctx.revert(); + }, []); + + // Horizontal scroll section + useEffect(() => { + const section = horizontalRef.current; + const inner = horizontalInnerRef.current; + if (!section || !inner) return; + + const ctx = gsap.context(() => { + const cards = inner.children; + const totalWidth = inner.scrollWidth - section.offsetWidth; + + gsap.to(inner, { + x: -totalWidth, + ease: 'none', + scrollTrigger: { + trigger: section, + start: 'top top', + end: () => `+=${totalWidth}`, + scrub: 1, + pin: true, + anticipatePin: 1, + }, + }); + }); + + return () => ctx.revert(); + }, []); + + return ( +
+ + {/* Real-time WebGL shader preloader overlay */} + + {!isSceneLoaded && ( + +
+ {/* Pulse heartbeat SVG */} + + + +

+ Compiling Volumetric Shaders +

+
+ +
+

+ Preloading GPU Textures... +

+
+
+ )} +
+ + {/* ============================================= + SECTION 1: HERO — Full screen dark canvas + ============================================= */} +
+ {/* Three.js particle canvas with shader compile feedback callback */} + setIsSceneLoaded(true)} /> + +
+ {/* Giant display title */} +

+ Every Second Counts +

+ + {/* Subtitle */} +

+ Smart emergency dispatch. Nearest hospital. Matched blood donors. + All in real time. +

+ + {/* CTA Buttons */} + + + + + + Get Help Now + + + 🩸 Register as Donor + + +
+ + {/* Scroll hint */} +
+ Scroll +
+
+
+
+
+ + {/* ============================================= + SECTION 2: PROBLEM STATEMENT — Text reveals + ============================================= */} +
+
+ + In an emergency, every minute between injury and hospital care determines survival. The golden hour is not a metaphor — it is a countdown. + + + + GoldenHour eliminates the chaos. One tap locks your GPS, dispatches the nearest hospital with a matching department, and broadcasts to every registered blood donor within range. Simultaneously. + +
+
+ + {/* ============================================= + SECTION 3: INTAKE CONSOLE — Pinned booking form + ============================================= */} +
+
+ + {/* Left: Descriptive text */} + +
+ + Emergency Console +
+

+ Lock. Dispatch. Save. +

+

+ Pin your GPS coordinates, select the emergency type and blood group needed, then hit dispatch. We route the request to the closest matching hospital and alert nearby donors instantly. +

+
+
+ + GPS Precision +
+
+ + Real-time Sync +
+
+ + Donor Alert +
+
+
+ + {/* Right: Intake Form Card */} + +
+ {/* Top accent */} +
+ + {/* Header */} +
+

Emergency Dispatch

+

Secure your location and select emergency details.

+
+ + {/* Location Button */} +
+ + + + {!coords && !locating && ( +
+ +
+ )} + + + {coords && ( + +

Coordinates Secured

+

Lat: {coords.lat} · Lng: {coords.lng}

+ +
+ )} + {locationError && ( + +

{locationError}

+
+ + | + +
+
+ )} +
+
+ + {/* Selects */} + setBloodGroup(e.target.value)} options={bloodOptions} /> + + {submitError && ( +
{submitError}
+ )} + + {/* Dispatch Button */} + +
+ + +
+
+ + {/* ============================================= + SECTION 4: HOW IT WORKS — Horizontal scroll + ============================================= */} +
+
+
+

How It Works

+

Three steps. Zero delay.

+
+
+ +
+ {/* Step 1 */} +
+
+
📍
+

Step 01

+

Lock Location

+

+ Your browser GPS pins your exact coordinates. High-accuracy mode ensures precision even in dense urban areas. +

+
+
+
+ + {/* Step 2 */} +
+
+
🏥
+

Step 02

+

Smart Dispatch

+

+ Our algorithm matches your emergency type to the nearest hospital with the right department and available bed capacity. +

+
+
+
+ + {/* Step 3 */} +
+
+
🩸
+

Step 03

+

Donor Alert

+

+ Every registered blood donor matching your blood type within range receives an instant alert with your location coordinates. +

+
+
+
+
+
+ + {/* ============================================= + SECTION 5: STATS — Animated counters + ============================================= */} +
+
+
+

By The Numbers

+

Built for speed. Designed for trust.

+
+ +
+ +
+ +
+

Average Response

+

From dispatch to hospital confirmation

+
+ + +
+ 24/7 +
+

Always On

+

Real-time websocket sync, polling fallback

+
+ + +
+ +
+

Open Source

+

Transparent, auditable, community-driven

+
+
+
+
+ + {/* ============================================= + SECTION 6: CTA FOOTER + ============================================= */} +
+
+ + When someone you love needs help, every second is a lifetime. + + +
+ + + + + Call 112 + + + 🩸 Become a Donor + +
+ + {/* Heartbeat line */} +
+ + + + + + + + + + + + +
+ + {/* Footer credit */} +

+ GoldenHour — Every Second Counts +

+
+
+ +
+ ); +} diff --git a/frontend/src/components/PatientResultsView.jsx b/frontend/src/components/PatientResultsView.jsx deleted file mode 100644 index 3a4a4d6..0000000 --- a/frontend/src/components/PatientResultsView.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; - -const fakeHospitals = [ - { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, department_match: true, status: "pending", phone: "+910000000000" }, - { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "confirmed", phone: "+910000000000" }, - { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, department_match: false, status: "pending", phone: "+910000000000" }, -]; - -function PatientResultsView() { - const { id } = useParams(); - - return ( -
-

Hospitals Nearby

- -
- {fakeHospitals.map(h => ( -
-
-

{h.name}

- - {h.status} - -
- -
- {h.eta_minutes} min ETA - {h.department_match && Dept match} -
- - - Call Hospital - -
- ))} -
- -
-
-
- 5 donors alerted nearby - 2 responded -
-
- {/* Blood drop icon */} - -
-
-
-
- ); -} - -export default PatientResultsView; diff --git a/frontend/src/components/PatientResultsView.tsx b/frontend/src/components/PatientResultsView.tsx new file mode 100644 index 0000000..4691600 --- /dev/null +++ b/frontend/src/components/PatientResultsView.tsx @@ -0,0 +1,426 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './ui/Card'; +import { Badge } from './ui/Badge'; +import { api } from '../lib/api'; +import { supabase } from '../lib/supabase'; + +interface Hospital { + hospital_id: string; + name: string; + eta_minutes: number; + department_match: boolean; + status: 'pending' | 'confirmed' | 'declined'; + phone: string; +} + +export default function PatientResultsView() { + const { id } = useParams<{ id: string }>(); + + // State initialized with all hospitals starting as "pending" + const [hospitals, setHospitals] = useState([ + { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, department_match: true, status: "pending", phone: "+910000000000" }, + { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, department_match: true, status: "pending", phone: "+910000000000" }, + { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, department_match: false, status: "pending", phone: "+910000000000" }, + ]); + + // Alert metrics — both come from the API (donors_alerted from the trigger, + // updated on every status poll). + const [donorsAlerted, setDonorsAlerted] = useState(0); + const [donorsResponded, setDonorsResponded] = useState(0); + + // loading skeleton & error states + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + // Mock mode is opt-in — defaults to real API; toggle persisted in localStorage + const [isMockMode, setIsMockMode] = useState(() => { + return localStorage.getItem('goldenhour_mock_mode') === 'true'; + }); + + const mountTime = useRef(Date.now()); + + // Save toggle choice in localStorage + useEffect(() => { + localStorage.setItem('goldenhour_mock_mode', String(isMockMode)); + }, [isMockMode]); + + // Process data returned from status payload + const updateStateFromPayload = (data: any) => { + if (typeof data.donors_alerted === 'number') setDonorsAlerted(data.donors_alerted); + setDonorsResponded(data.donors_responded ?? 0); + if (Array.isArray(data.hospitals)) { + setHospitals(prev => { + return data.hospitals.map((newH: any) => { + const existing = prev.find(h => h.hospital_id === newH.hospital_id); + return { + hospital_id: newH.hospital_id, + name: newH.name, + eta_minutes: newH.eta_minutes, + status: newH.status, + department_match: existing ? existing.department_match : (newH.department_match ?? false), + phone: existing ? existing.phone : (newH.phone ?? "+910000000000") + }; + }); + }); + } + }; + + // Poll status endpoint or run offline simulation + const pollStatus = async (requestId: string) => { + if (isMockMode) { + const elapsed = Date.now() - mountTime.current; + // After ~4s, Fortis Jaipur flips from pending to confirmed, Manipal Jaipur flips to declined, and responded goes to 2 + const isAfter4s = elapsed >= 4000; + const isAfter8s = elapsed >= 8000; + + const mockPayload = { + request_id: requestId, + hospitals: [ + { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, status: "pending" as const }, + { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, status: isAfter4s ? "confirmed" as const : "pending" as const }, + { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, status: isAfter8s ? "declined" as const : "pending" as const } + ], + donors_alerted: 5, + donors_responded: isAfter4s ? 2 : 0 + }; + + updateStateFromPayload(mockPayload); + setHasError(false); + } else { + try { + const data = await api.getEmergencyStatus(requestId); + updateStateFromPayload(data); + setHasError(false); + } catch (err) { + console.error('Network error during status polling:', err); + setHasError(true); + } + } + }; + + // Interval polling subscription (serves as fallback if Supabase socket drops) + useEffect(() => { + if (!id) return; + + setIsLoading(true); + setHasError(false); + + // Initial 1.2s delay for a premium skeleton load animation + const skeletonTimer = setTimeout(() => { + setIsLoading(false); + }, 1200); + + pollStatus(id); + + const intervalId = setInterval(() => { + pollStatus(id); + }, 3000); + + return () => { + clearInterval(intervalId); + clearTimeout(skeletonTimer); + }; + }, [id, isMockMode]); + + // Supabase Realtime subscription (live socket push changes) + useEffect(() => { + if (!id || isMockMode) return; + + console.log(`Establishing Supabase Realtime channel for request: ${id}`); + + const channel = supabase + .channel(`emergency-all-${id}`) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'confirmation_requests', + filter: `emergency_id=eq.${id}` + }, + (payload: any) => { + console.log('Realtime DB update received from confirmation_requests:', payload); + const updatedConf = payload.new; + if (updatedConf) { + setHospitals(prev => + prev.map(h => + h.hospital_id === updatedConf.hospital_id + ? { + ...h, + status: updatedConf.confirmed === true + ? 'confirmed' + : updatedConf.confirmed === false + ? 'declined' + : 'pending' + } + : h + ) + ); + } + } + ) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'emergency_requests', + filter: `id=eq.${id}` + }, + (payload: any) => { + console.log('Realtime DB update received from emergency_requests:', payload); + const updatedReq = payload.new; + if (updatedReq) { + setDonorsResponded(updatedReq.donors_responded ?? 0); + if (updatedReq.hospitals) { + setHospitals(prev => { + return updatedReq.hospitals.map((newH: any) => { + const existing = prev.find(h => h.hospital_id === newH.hospital_id); + return { + hospital_id: newH.hospital_id, + name: newH.name, + eta_minutes: newH.eta_minutes, + status: newH.status, + department_match: existing ? existing.department_match : (newH.department_match ?? false), + phone: existing ? existing.phone : (newH.phone ?? "+910000000000") + }; + }); + }); + } + } + } + ) + .subscribe((status) => { + console.log(`Supabase Realtime subscription status: ${status}`); + }); + + return () => { + console.log(`Teardown Supabase Realtime channel for request: ${id}`); + supabase.removeChannel(channel); + }; + }, [id, isMockMode]); + + // Sort: Confirmed float to top, then sorted by ETA (shortest first), declined at the very bottom + const sortedHospitals = [...hospitals].sort((a, b) => { + if (a.status === 'confirmed' && b.status !== 'confirmed') return -1; + if (a.status !== 'confirmed' && b.status === 'confirmed') return 1; + if (a.status === 'declined' && b.status !== 'declined') return 1; + if (a.status !== 'declined' && b.status === 'declined') return -1; + return a.eta_minutes - b.eta_minutes; + }); + + return ( +
+ + {/* Top Status Header */} +
+
+ {/* Pulsing indicator */} + + + + + Finding help… +
+
+ + + ID: {id} + +
+
+ + {/* Hospital Cards (AnimatePresence for layout transition animations) */} +
+ + {isLoading ? ( + + {[1, 2, 3].map((idx) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} + + ) : hasError ? ( + +
+ ! +
+

Connection Issue

+

+ We are having trouble contacting the dispatch server. Please check your connection. +

+ +
+ ) : sortedHospitals.length === 0 ? ( + +
+ ? +
+

No Responders Found

+

+ We couldn't locate any hospital dispatch units in your immediate radius. +

+
+ ) : ( + sortedHospitals.map((h, index) => { + const isConfirmed = h.status === 'confirmed'; + const isDeclined = h.status === 'declined'; + + return ( + + + {/* Glowing amber branding highlight on confirmed card */} + {isConfirmed && ( +
+ )} + +
+
+

+ {h.name} +

+ + {h.department_match ? ( + + Dept ✓ matched + + ) : ( + + Dept mismatch + + )} +
+ + +
+ + {/* Badging: ETA */} +
+ + + {h.eta_minutes} min ETA + +
+ + {/* 1-tap Call Button */} + + + {isConfirmed ? 'Establish Call Link' : 'Call Dispatch'} + + + + ); + }) + )} + +
+ + {/* Pinned Bottom Donor Panel */} +
+
+
+ + + + + {isLoading ? 'Locating donors nearby…' : `${donorsAlerted} donors alerted nearby`} + + + {donorsResponded > 0 ? `${donorsResponded} responded` : 'Waiting for responses'} + +
+ + {/* Blood drop placeholder badge */} +
+ + + +
+
+
+ +
+ ); +} diff --git a/frontend/src/components/motion/CardReveal.tsx b/frontend/src/components/motion/CardReveal.tsx new file mode 100644 index 0000000..e90fd6e --- /dev/null +++ b/frontend/src/components/motion/CardReveal.tsx @@ -0,0 +1,66 @@ +import React, { useRef, useEffect } from 'react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; + +interface CardRevealProps { + children: React.ReactNode; + className?: string; + direction?: 'left' | 'right' | 'bottom'; + delay?: number; +} + +/** + * WebGL-inspired card entrance using diagonal clip-path wipe + scale + opacity. + * Triggered on scroll intersection. + */ +export const CardReveal: React.FC = ({ + children, + className = '', + direction = 'bottom', + delay = 0, +}) => { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + // Set initial state + gsap.set(el, { + opacity: 0, + y: direction === 'bottom' ? 60 : 0, + x: direction === 'left' ? -60 : direction === 'right' ? 60 : 0, + scale: 0.95, + clipPath: direction === 'left' + ? 'polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)' + : direction === 'right' + ? 'polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)' + : 'polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)', + }); + + const tween = gsap.to(el, { + opacity: 1, + y: 0, + x: 0, + scale: 1, + clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)', + duration: 1.2, + delay, + ease: 'power3.out', + scrollTrigger: { + trigger: el, + start: 'top 88%', + toggleActions: 'play none none none', + }, + }); + + return () => { + tween.kill(); + }; + }, [direction, delay]); + + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/motion/CountUp.tsx b/frontend/src/components/motion/CountUp.tsx new file mode 100644 index 0000000..4b9c7a8 --- /dev/null +++ b/frontend/src/components/motion/CountUp.tsx @@ -0,0 +1,63 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; + +interface CountUpProps { + end: number; + prefix?: string; + suffix?: string; + duration?: number; + className?: string; + decimals?: number; +} + +/** + * Animated number counter that counts from 0 to target value + * when scrolled into view via GSAP ScrollTrigger. + */ +export const CountUp: React.FC = ({ + end, + prefix = '', + suffix = '', + duration = 2, + className = '', + decimals = 0, +}) => { + const ref = useRef(null); + const [hasAnimated, setHasAnimated] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || hasAnimated) return; + + const counter = { value: 0 }; + + ScrollTrigger.create({ + trigger: el, + start: 'top 85%', + once: true, + onEnter: () => { + setHasAnimated(true); + gsap.to(counter, { + value: end, + duration, + ease: 'power2.out', + onUpdate: () => { + el.textContent = `${prefix}${counter.value.toFixed(decimals)}${suffix}`; + }, + }); + }, + }); + + return () => { + ScrollTrigger.getAll().forEach(st => { + if (st.trigger === el) st.kill(); + }); + }; + }, [end, prefix, suffix, duration, decimals, hasAnimated]); + + return ( + + {prefix}0{suffix} + + ); +}; diff --git a/frontend/src/components/motion/ParallaxSection.tsx b/frontend/src/components/motion/ParallaxSection.tsx new file mode 100644 index 0000000..f64098e --- /dev/null +++ b/frontend/src/components/motion/ParallaxSection.tsx @@ -0,0 +1,46 @@ +import React, { useRef, useEffect } from 'react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; + +interface ParallaxSectionProps { + children: React.ReactNode; + speed?: number; + className?: string; +} + +/** + * Parallax wrapper — translates children on the Y axis + * based on scroll position for a layered depth effect. + */ +export const ParallaxSection: React.FC = ({ + children, + speed = 0.3, + className = '', +}) => { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const tween = gsap.to(el, { + y: () => speed * 100, + ease: 'none', + scrollTrigger: { + trigger: el, + start: 'top bottom', + end: 'bottom top', + scrub: true, + }, + }); + + return () => { + tween.kill(); + }; + }, [speed]); + + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/motion/TextReveal.tsx b/frontend/src/components/motion/TextReveal.tsx new file mode 100644 index 0000000..f8f1273 --- /dev/null +++ b/frontend/src/components/motion/TextReveal.tsx @@ -0,0 +1,98 @@ +import React, { useRef, useEffect } from 'react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; + +interface TextRevealProps { + children: string; + as?: 'h1' | 'h2' | 'h3' | 'p' | 'span'; + className?: string; + splitBy?: 'words' | 'chars' | 'lines'; + stagger?: number; + scrub?: boolean | number; + start?: string; + end?: string; +} + +/** + * Scroll-driven text reveal component. + * Splits text into words/chars and reveals them with clip-path + opacity + * as the user scrolls — inspired by Shopify Editions editorial style. + */ +export const TextReveal: React.FC = ({ + children, + as: Tag = 'p', + className = '', + splitBy = 'words', + stagger = 0.04, + scrub = true, + start = 'top 85%', + end = 'top 25%', +}) => { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + // Split text into individual span elements + const text = el.textContent || ''; + const units = splitBy === 'chars' + ? text.split('') + : text.split(/\s+/).filter(Boolean); + + el.innerHTML = ''; + const spans: HTMLSpanElement[] = []; + + units.forEach((unit, i) => { + const span = document.createElement('span'); + span.style.display = 'inline-block'; + span.style.overflow = 'hidden'; + + const inner = document.createElement('span'); + inner.textContent = unit; + inner.style.display = 'inline-block'; + inner.style.opacity = '0.15'; + inner.style.transform = 'translateY(20%)'; + inner.style.transition = 'none'; + + span.appendChild(inner); + el.appendChild(span); + spans.push(inner); + + // Add space between words + if (splitBy === 'words' && i < units.length - 1) { + const space = document.createTextNode('\u00A0'); + el.appendChild(space); + } + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start, + end, + scrub: scrub === true ? 1 : scrub, + }, + }); + + tl.to(spans, { + opacity: 1, + y: '0%', + duration: 0.8, + stagger, + ease: 'power2.out', + }); + + return () => { + tl.kill(); + ScrollTrigger.getAll().forEach(st => { + if (st.trigger === el) st.kill(); + }); + }; + }, [children, splitBy, stagger, scrub, start, end]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/three/HeroScene.tsx b/frontend/src/components/three/HeroScene.tsx new file mode 100644 index 0000000..3702657 --- /dev/null +++ b/frontend/src/components/three/HeroScene.tsx @@ -0,0 +1,418 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import * as THREE from 'three'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; +import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; +import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; + +// Custom shader for chromatic aberration, film grain, and soft volumetric glow +const CustomPostShader = { + uniforms: { + tDiffuse: { value: null }, + uChromaticAberration: { value: 0.02 }, + uTime: { value: 0 }, + uGrainIntensity: { value: 0.03 } + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + uniform float uChromaticAberration; + uniform float uTime; + uniform float uGrainIntensity; + varying vec2 vUv; + + // Pseudo-random noise for grain + float random(vec2 coords) { + return fract(sin(dot(coords.xy, vec2(12.9898, 78.233))) * 43758.5453); + } + + void main() { + vec2 uv = vUv; + + // 1. Radial Chromatic Aberration + vec2 distVec = uv - 0.5; + float dist = length(distVec); + float shift = uChromaticAberration * dist * dist; + + vec4 rCol = texture2D(tDiffuse, uv - distVec * shift); + vec4 gCol = texture2D(tDiffuse, uv); + vec4 bCol = texture2D(tDiffuse, uv + distVec * shift); + + vec4 color = vec4(rCol.r, gCol.g, bCol.b, gCol.a); + + // 2. Soft Atmospheric Glow (lightweight blurred blend) + vec4 blur = vec4(0.0); + float blurScale = 0.003; + blur += texture2D(tDiffuse, uv + vec2(-1.0, -1.0) * blurScale); + blur += texture2D(tDiffuse, uv + vec2(1.0, -1.0) * blurScale); + blur += texture2D(tDiffuse, uv + vec2(-1.0, 1.0) * blurScale); + blur += texture2D(tDiffuse, uv + vec2(1.0, 1.0) * blurScale); + blur /= 4.0; + + color += max(blur - 0.15, 0.0) * 0.35; + + // 3. Film Grain + float noise = random(uv + vec2(sin(uTime * 0.1), cos(uTime * 0.1))); + color.rgb += (noise - 0.5) * uGrainIntensity; + + gl_FragColor = color; + } + ` +}; + +export const HeroScene: React.FC<{ className?: string; onLoaded?: () => void }> = ({ className = '', onLoaded }) => { + const canvasRef = useRef(null); + + // Track parameters across rendering loop + const sceneRef = useRef<{ + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + renderer: THREE.WebGLRenderer; + composer: EffectComposer | null; + particles: THREE.Points; + originalPositions: Float32Array; + scrollProgress: { value: number }; + breathe: { value: number }; + mouse: { x: number; y: number; targetX: number; targetY: number }; + tickHandler: (time: number) => void; + clock: THREE.Clock; + isMobile: boolean; + reducedMotion: boolean; + } | null>(null); + + const initScene = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const width = canvas.clientWidth; + const height = canvas.clientHeight; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100); + camera.position.z = 4.2; + + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + powerPreference: 'high-performance' + }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setClearColor(0x000000, 0); + + // Adaptive constraints based on device and user settings + const isMobile = window.innerWidth < 768; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const PARTICLE_COUNT = isMobile ? 500 : 1500; + + // Heart coordinate generation logic + const positions = new Float32Array(PARTICLE_COUNT * 3); + const colors = new Float32Array(PARTICLE_COUNT * 3); + const sizes = new Float32Array(PARTICLE_COUNT); + const originalPositions = new Float32Array(PARTICLE_COUNT * 3); + + const crimson = new THREE.Color('#DC2626'); + const amber = new THREE.Color('#F59E0B'); + const warmWhite = new THREE.Color('#FEF3C7'); + + for (let i = 0; i < PARTICLE_COUNT; i++) { + const i3 = i * 3; + const t = Math.random() * Math.PI * 2; + const heartX = 16 * Math.pow(Math.sin(t), 3); + const heartY = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); + + const scale = 0.08; + const noise = 0.45 + Math.random() * 0.55; + positions[i3] = heartX * scale * noise + (Math.random() - 0.5) * 0.7; + positions[i3 + 1] = heartY * scale * noise + (Math.random() - 0.5) * 0.7 + 0.35; + positions[i3 + 2] = (Math.random() - 0.5) * 1.5; + + originalPositions[i3] = positions[i3]; + originalPositions[i3 + 1] = positions[i3 + 1]; + originalPositions[i3 + 2] = positions[i3 + 2]; + + const colorChoice = Math.random(); + let color: THREE.Color; + if (colorChoice < 0.45) { + color = crimson.clone().lerp(amber, Math.random()); + } else if (colorChoice < 0.8) { + color = amber.clone().lerp(warmWhite, Math.random() * 0.4); + } else { + color = warmWhite.clone(); + } + colors[i3] = color.r; + colors[i3 + 1] = color.g; + colors[i3 + 2] = color.b; + + sizes[i] = Math.random() * (isMobile ? 3 : 5.5) + 1.5; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const material = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }, + }, + vertexShader: ` + attribute float size; + varying vec3 vColor; + uniform float uTime; + uniform float uPixelRatio; + + void main() { + vColor = color; + vec3 pos = position; + + // Subtle spatial ambient sway + pos.y += sin(uTime * 0.4 + position.x * 2.0) * 0.035; + pos.x += cos(uTime * 0.25 + position.y * 1.5) * 0.025; + + vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); + gl_PointSize = size * uPixelRatio * (1.0 / -mvPosition.z); + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + varying vec3 vColor; + + void main() { + float dist = length(gl_PointCoord - vec2(0.5)); + if (dist > 0.5) discard; + + // Soft volumetric alpha falloff + float alpha = 1.0 - smoothstep(0.0, 0.5, dist); + alpha *= 0.65; + + gl_FragColor = vec4(vColor, alpha); + } + `, + transparent: true, + vertexColors: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + const particles = new THREE.Points(geometry, material); + scene.add(particles); + + // Dynamic animation parameters + const scrollProgress = { value: 0 }; + const breathe = { value: 0 }; + const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }; + + // Post-processing setup (Disable on mobile or reduced motion for low overhead) + let composer: EffectComposer | null = null; + let customPass: ShaderPass | null = null; + + if (!isMobile && !reducedMotion) { + composer = new EffectComposer(renderer); + + const renderPass = new RenderPass(scene, camera); + composer.addPass(renderPass); + + customPass = new ShaderPass(CustomPostShader); + customPass.uniforms.uChromaticAberration.value = 0.016; + customPass.uniforms.uGrainIntensity.value = 0.035; + composer.addPass(customPass); + } + + // Pre-compile shaders in background for stutter-free scrolling + renderer.compile(scene, camera); + if (onLoaded) { + setTimeout(onLoaded, 100); + } + + // Scroll trigger setup + const trigger = ScrollTrigger.create({ + trigger: canvas.parentElement, + start: 'top top', + end: 'bottom top', + scrub: 0.8, + onUpdate: (self) => { + scrollProgress.value = self.progress; + }, + }); + + // Breathing loop using GSAP + let breatheTween: gsap.core.Tween | null = null; + if (!reducedMotion) { + breatheTween = gsap.to(breathe, { + value: 1, + duration: 3, + ease: 'sine.inOut', + repeat: -1, + yoyo: true, + }); + } + + const clock = new THREE.Clock(); + + // Centralized single render tick handler registered directly to GSAP ticker + const tickHandler = (time: number) => { + const ref = sceneRef.current; + if (!ref) return; + + const elapsed = clock.getElapsedTime(); + (ref.particles.material as THREE.ShaderMaterial).uniforms.uTime.value = elapsed; + + // Apply dynamic parameters + const posAttr = ref.particles.geometry.attributes.position; + const pos = posAttr.array as Float32Array; + const orig = ref.originalPositions; + const breathScale = ref.reducedMotion ? 1 : (1 + ref.breathe.value * 0.05); + const dispersal = ref.scrollProgress.value; + + // Elastic mouse hover coordinates interpolation + if (!ref.reducedMotion && !ref.isMobile) { + ref.mouse.x += (ref.mouse.targetX - ref.mouse.x) * 0.06; + ref.mouse.y += (ref.mouse.targetY - ref.mouse.y) * 0.06; + + // Apply visual drift based on mouse coordinate & scroll offset + ref.particles.rotation.y = elapsed * 0.04 + ref.mouse.x * 0.12; + ref.particles.rotation.x = ref.mouse.y * 0.08; + } else { + ref.particles.rotation.y = elapsed * 0.02; + } + + // Re-position particles relative to scroll dispersal progress + for (let i = 0; i < pos.length; i += 3) { + // High frequency particle dispersion factor + const randX = (Math.sin(i + elapsed) * 0.5) * dispersal * 7.5; + const randY = (Math.cos(i + elapsed) * 0.5) * dispersal * 7.5; + const randZ = (Math.sin(i * 0.5 + elapsed) * 0.5) * dispersal * 4.5; + + pos[i] = orig[i] * breathScale + randX; + pos[i + 1] = orig[i + 1] * breathScale + randY; + pos[i + 2] = orig[i + 2] + randZ; + } + posAttr.needsUpdate = true; + + // Fade particles out gradually on scroll down + (ref.particles.material as THREE.ShaderMaterial).opacity = 1 - dispersal * 0.85; + + // Render via Composer post-processing or direct WebGL renderer + if (ref.composer) { + if (customPass) { + customPass.uniforms.uTime.value = elapsed; + } + ref.composer.render(); + } else { + ref.renderer.render(ref.scene, ref.camera); + } + }; + + // Add to unified GSAP requestAnimationFrame loop + gsap.ticker.add(tickHandler); + + sceneRef.current = { + scene, + camera, + renderer, + composer, + particles, + originalPositions, + scrollProgress, + breathe, + mouse, + tickHandler, + clock, + isMobile, + reducedMotion + }; + + // Track mouse inputs + const handleMouseMove = (e: MouseEvent) => { + const ref = sceneRef.current; + if (!ref || ref.reducedMotion || ref.isMobile) return; + ref.mouse.targetX = (e.clientX / window.innerWidth - 0.5) * 2; + ref.mouse.targetY = -(e.clientY / window.innerHeight - 0.5) * 2; + }; + + if (!isMobile && !reducedMotion) { + window.addEventListener('mousemove', handleMouseMove); + } + + // Save triggers and tweens for clean disposal + return () => { + gsap.ticker.remove(tickHandler); + window.removeEventListener('mousemove', handleMouseMove); + trigger.kill(); + if (breatheTween) breatheTween.kill(); + }; + }, []); + + // Window resize and tab visibility management (Pause when hidden) + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const cleanupScene = initScene(); + + const handleResize = () => { + const ref = sceneRef.current; + if (!ref || !canvas) return; + + const width = canvas.clientWidth; + const height = canvas.clientHeight; + + ref.camera.aspect = width / height; + ref.camera.updateProjectionMatrix(); + ref.renderer.setSize(width, height); + if (ref.composer) { + ref.composer.setSize(width, height); + } + }; + + const handleVisibility = () => { + const ref = sceneRef.current; + if (!ref) return; + + // Pause ticker subscription if browser tab goes off-screen to prevent battery drain + if (document.hidden) { + gsap.ticker.remove(ref.tickHandler); + } else { + gsap.ticker.add(ref.tickHandler); + } + }; + + window.addEventListener('resize', handleResize); + document.addEventListener('visibilitychange', handleVisibility); + + return () => { + window.removeEventListener('resize', handleResize); + document.removeEventListener('visibilitychange', handleVisibility); + if (cleanupScene) cleanupScene(); + + const ref = sceneRef.current; + if (ref) { + ref.renderer.dispose(); + ref.particles.geometry.dispose(); + (ref.particles.material as THREE.ShaderMaterial).dispose(); + if (ref.composer) { + // Dispose postprocessing passes + ref.composer.dispose(); + } + } + sceneRef.current = null; + }; + }, [initScene]); + + return ( + + ); +}; diff --git a/frontend/src/components/ui/AppBar.tsx b/frontend/src/components/ui/AppBar.tsx new file mode 100644 index 0000000..22e7c60 --- /dev/null +++ b/frontend/src/components/ui/AppBar.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { gsap } from '../../lib/gsap-setup'; + +export const AppBar: React.FC = () => { + const location = useLocation(); + const isHome = location.pathname === '/'; + const headerRef = useRef(null); + const [hidden, setHidden] = useState(false); + const lastScrollY = useRef(0); + + // Hide on scroll down, show on scroll up (home page only) + useEffect(() => { + if (!isHome) { + setHidden(false); + return; + } + + const handleScroll = () => { + const currentY = window.scrollY; + if (currentY > lastScrollY.current && currentY > 100) { + setHidden(true); + } else { + setHidden(false); + } + lastScrollY.current = currentY; + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, [isHome]); + + // Animate hide/show + useEffect(() => { + if (!headerRef.current) return; + gsap.to(headerRef.current, { + y: hidden ? -100 : 0, + duration: 0.4, + ease: 'power2.out', + }); + }, [hidden]); + + return ( +
+
+ +
+ {/* Heartbeat pulse rings */} + + + + + + +
+ + + GoldenHour + + + + {/* Nav links (home page) */} + {isHome && ( + + )} + + {/* Live system state badge */} +
+ + + + + Console Active +
+
+
+ ); +}; diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..7c26a39 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export interface BadgeProps { + status: 'pending' | 'confirmed' | 'declined'; + className?: string; +} + +export const Badge: React.FC = ({ status, className = '' }) => { + // Styles aligned with calm urgency theme and DESIGN.md + const baseStyle = "inline-flex items-center px-3 py-1 rounded-full text-xs font-black uppercase tracking-wider select-none border"; + + const statusStyles = { + pending: "bg-amber-500/5 text-amber-600 border-amber-500/20", + confirmed: "bg-success/5 text-success border-success/20", + declined: "bg-slate-200/50 text-slate-500 border-slate-300/40", + }; + + const statusLabels = { + pending: 'Pending', + confirmed: 'Confirmed', + declined: 'Declined', + }; + + return ( + + {status === 'pending' && ( + + )} + {status === 'confirmed' && ( + + )} + {status === 'declined' && ( + + )} + {statusLabels[status]} + + ); +}; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..f5b1360 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'emergency' | 'primary' | 'success' | 'ghost'; + isLoading?: boolean; + fullWidth?: boolean; +} + +export const Button: React.FC = ({ + children, + variant = 'primary', + isLoading = false, + fullWidth = false, + className = '', + disabled, + ...props +}) => { + // Base classes optimized for emergency gloves (minimum 56px target) + const baseStyle = "relative flex items-center justify-center h-14 px-6 rounded-xl font-extrabold tracking-wide transition-all focus:outline-none focus-visible:ring-4 disabled:opacity-50 disabled:cursor-not-allowed select-none cursor-pointer"; + + const widthStyle = fullWidth ? "w-full" : ""; + + // Custom design token styling matching DESIGN.md + const variantStyles = { + emergency: "bg-emergency text-white hover:bg-red-700 focus-visible:ring-red-500/20 active:bg-emergency-pressed shadow-[0_4px_12px_rgba(220,38,38,0.2)]", + primary: "bg-ink text-white hover:bg-ink-muted focus-visible:ring-slate-500/20 active:bg-slate-900 shadow-[0_4px_12px_rgba(26,23,20,0.15)]", + success: "bg-success text-white hover:bg-emerald-700 focus-visible:ring-emerald-500/20 active:bg-emerald-800 shadow-[0_4px_12px_rgba(5,150,105,0.2)]", + ghost: "bg-transparent text-ink border-2 border-slate-200 hover:bg-slate-50 hover:border-slate-300 focus-visible:ring-slate-500/20 active:bg-slate-100" + }; + + return ( + + {isLoading ? ( + + {/* Spinner icon */} + + + + + Processing... + + ) : ( + children + )} + + ); +}; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..13b8b29 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export interface CardProps extends React.HTMLAttributes { + animateEntrance?: boolean; + delayIndex?: number; +} + +export const Card: React.FC = ({ + children, + animateEntrance = true, + delayIndex = 0, + className = '', + ...props +}) => { + const cardStyle = `bg-white rounded-2xl p-5 shadow-layered border border-slate-100/30 overflow-hidden relative ${className}`; + + if (!animateEntrance) { + return ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/ui/CustomCursor.tsx b/frontend/src/components/ui/CustomCursor.tsx new file mode 100644 index 0000000..ef3b7bc --- /dev/null +++ b/frontend/src/components/ui/CustomCursor.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { gsap } from '../../lib/gsap-setup'; + +export const CustomCursor: React.FC = () => { + const dotRef = useRef(null); + const ringRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isInput, setIsInput] = useState(false); + const [isMagnetic, setIsMagnetic] = useState(false); + + useEffect(() => { + // 1. Accessibility & Device Fallbacks + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + // Disable custom cursor on touchscreens or when reduced motion is preferred + if (reducedMotion || isTouchDevice) { + return; + } + + const dot = dotRef.current; + const ring = ringRef.current; + if (!dot || !ring) return; + + // Set initial offscreen positions + gsap.set([dot, ring], { xPercent: -50, yPercent: -50 }); + + // GSAP quickTo for 60fps tracking without triggering React renders + const xToDot = gsap.quickTo(dot, 'x', { duration: 0.08, ease: 'power3.out' }); + const yToDot = gsap.quickTo(dot, 'y', { duration: 0.08, ease: 'power3.out' }); + + const xToRing = gsap.quickTo(ring, 'x', { duration: 0.35, ease: 'power2.out' }); + const yToRing = gsap.quickTo(ring, 'y', { duration: 0.35, ease: 'power2.out' }); + + let currentMagneticElement: HTMLElement | null = null; + + const onMouseMove = (e: MouseEvent) => { + if (!isVisible) setIsVisible(true); + + const target = e.target as HTMLElement; + + // Handle Magnetic Attraction + if (currentMagneticElement) { + const rect = currentMagneticElement.getBoundingClientRect(); + // Calculate center of target element + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // Pull cursor ring toward target center (strength increases near center) + const pullFactor = 0.45; + const targetX = centerX + (e.clientX - centerX) * pullFactor; + const targetY = centerY + (e.clientY - centerY) * pullFactor; + + xToDot(e.clientX); + yToDot(e.clientY); + xToRing(targetX); + yToRing(targetY); + + // Magnetically shift the element slightly too for tactile feedback! + const shiftX = (e.clientX - centerX) * 0.2; + const shiftY = (e.clientY - centerY) * 0.2; + gsap.to(currentMagneticElement, { + x: shiftX, + y: shiftY, + duration: 0.3, + ease: 'power2.out', + }); + } else { + // Normal tracking + xToDot(e.clientX); + yToDot(e.clientY); + xToRing(e.clientX); + yToRing(e.clientY); + } + }; + + const onMouseEnterInteractive = (e: Event) => { + const el = e.currentTarget as HTMLElement; + setIsHovered(true); + + const isMag = el.tagName === 'BUTTON' || el.classList.contains('magnetic-btn') || el.hasAttribute('data-magnetic'); + const isField = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'; + + setIsInput(isField); + setIsMagnetic(isMag); + + if (isMag) { + currentMagneticElement = el; + // Ring expands and snaps outline + gsap.to(ring, { + width: el.offsetWidth + 16, + height: el.offsetHeight + 16, + borderRadius: gsap.getProperty(el, 'borderRadius') as string || '12px', + borderColor: 'rgba(245, 158, 11, 0.65)', + backgroundColor: 'rgba(245, 158, 11, 0.04)', + duration: 0.3, + ease: 'power2.out' + }); + gsap.to(dot, { + scale: 0, + duration: 0.2 + }); + } else if (isField) { + // Stretch ring to vertically thin focus indicator + gsap.to(ring, { + scale: 1.4, + borderWidth: '1.5px', + borderColor: 'rgba(220, 38, 38, 0.4)', + duration: 0.25 + }); + } else { + // Generic hover zoom + gsap.to(ring, { + scale: 1.6, + borderColor: 'rgba(245, 158, 11, 0.6)', + duration: 0.25 + }); + } + }; + + const onMouseLeaveInteractive = () => { + setIsHovered(false); + setIsInput(false); + setIsMagnetic(false); + + if (currentMagneticElement) { + // Reset the shifted magnetic button back to its origin + gsap.to(currentMagneticElement, { + x: 0, + y: 0, + duration: 0.4, + ease: 'elastic.out(1, 0.4)', + }); + currentMagneticElement = null; + } + + // Restore default cursor dimensions + gsap.to(ring, { + width: 32, + height: 32, + scale: 1, + borderRadius: '50%', + borderColor: 'rgba(245, 158, 11, 0.3)', + backgroundColor: 'transparent', + borderWidth: '2px', + duration: 0.3, + ease: 'power2.out' + }); + gsap.to(dot, { + scale: 1, + duration: 0.2 + }); + }; + + // Attach listeners globally via event delegation or direct selection + const updateInteractiveListeners = () => { + // Find all interactive elements + const elements = document.querySelectorAll('a, button, input, select, [data-magnetic], .cursor-pointer-target'); + elements.forEach((el) => { + el.addEventListener('mouseenter', onMouseEnterInteractive); + el.addEventListener('mouseleave', onMouseLeaveInteractive); + }); + }; + + window.addEventListener('mousemove', onMouseMove); + updateInteractiveListeners(); + + // Re-bind listeners when page DOM changes (e.g. intake form states) + const observer = new MutationObserver(() => { + updateInteractiveListeners(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + observer.disconnect(); + const elements = document.querySelectorAll('a, button, input, select, [data-magnetic]'); + elements.forEach((el) => { + el.removeEventListener('mouseenter', onMouseEnterInteractive); + el.removeEventListener('mouseleave', onMouseLeaveInteractive); + }); + }; + }, []); + + return ( +
+ {/* Outer Follower Ring */} +
+ {/* Inner Pin Dot */} +
+
+ ); +}; diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..faa4b23 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,43 @@ +import React, { useId } from 'react'; + +export interface InputProps extends React.InputHTMLAttributes { + label: string; + error?: string; +} + +export const Input: React.FC = ({ + label, + error, + className = '', + ...props +}) => { + const id = useId(); + + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..4492218 --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,61 @@ +import React, { useId } from 'react'; + +export interface SelectProps extends React.SelectHTMLAttributes { + label: string; + options: { value: string; label: string }[]; + error?: string; +} + +export const Select: React.FC = ({ + label, + options, + error, + className = '', + ...props +}) => { + const id = useId(); + + return ( +
+ + +
+ +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 55d2777..a77eccb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,282 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - body { - @apply antialiased text-gray-900 bg-gray-50; - } +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800;900&display=swap'); +@import "tailwindcss"; + +@theme { + --font-sans: 'Inter', system-ui, sans-serif; + --font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif; + + --color-bg-warm: #FBFAF8; + --color-surface: #FFFFFF; + --color-ink: #1A1714; + --color-ink-muted: #6B6560; + + --color-dark-bg: #0A0A0F; + --color-dark-surface: #14141F; + --color-dark-ink: #F0EDE8; + --color-dark-ink-muted: #8A8580; + + --color-emergency: #DC2626; + --color-emergency-pressed: #B91C1C; + --color-success: #059669; + --color-pending: #9CA3AF; + --color-goldenhour: #F59E0B; + + --shadow-layered: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04); +} + +:root { + --color-bg: #FBFAF8; + --color-surface: #FFFFFF; + --color-ink: #1A1714; + --color-ink-muted: #6B6560; + + --color-emergency: #DC2626; + --color-emergency-pressed: #B91C1C; + --color-success: #059669; + --color-pending: #9CA3AF; + --color-goldenhour: #F59E0B; +} + +/* Dark theme for home/hero */ +[data-theme="dark"] { + --color-bg: #0A0A0F; + --color-surface: #14141F; + --color-ink: #F0EDE8; + --color-ink-muted: #8A8580; +} + +/* ======================== + KEYFRAMES + ======================== */ + +@keyframes flash-red { + 0%, 100% { opacity: 0.3; fill: #DC2626; filter: drop-shadow(0 0 2px rgba(220,38,38,0.2)); } + 50% { opacity: 1; fill: #EF4444; filter: drop-shadow(0 0 8px rgba(220,38,38,0.8)); } +} + +@keyframes flash-amber { + 0%, 100% { opacity: 1; fill: #FBBF24; filter: drop-shadow(0 0 8px rgba(245,158,11,0.8)); } + 50% { opacity: 0.3; fill: #F59E0B; filter: drop-shadow(0 0 2px rgba(245,158,11,0.2)); } +} + +@keyframes sway { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-1.5px) rotate(0.2deg); } +} + +@keyframes wheel-vibrate { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.5px); } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.1), 0 0 60px rgba(220, 38, 38, 0.05); } + 50% { box-shadow: 0 0 40px rgba(245, 158, 11, 0.2), 0 0 100px rgba(220, 38, 38, 0.1); } +} + +@keyframes scroll-hint { + 0%, 100% { opacity: 0.4; transform: translateY(0); } + 50% { opacity: 1; transform: translateY(8px); } +} + +@keyframes heartbeat-line { + 0% { stroke-dashoffset: 1000; } + 100% { stroke-dashoffset: 0; } +} + +@keyframes gradient-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.animate-flash-red { + animation: flash-red 0.6s infinite alternate; +} + +.animate-flash-amber { + animation: flash-amber 0.6s infinite alternate; +} + +.animate-sway { + animation: sway 2.5s ease-in-out infinite; +} + +.animate-wheel { + animation: wheel-vibrate 0.15s linear infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 3s ease-in-out infinite; +} + +.animate-scroll-hint { + animation: scroll-hint 2s ease-in-out infinite; +} + +.animate-gradient-shift { + background-size: 200% 200%; + animation: gradient-shift 4s ease-in-out infinite; +} + +/* ======================== + GLOBAL STYLES + ======================== */ + +body { + background-color: var(--color-bg); + color: var(--color-ink); + font-family: 'Inter', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Lenis smooth scroll */ +html.lenis, html.lenis body { + height: auto; +} + +.lenis.lenis-smooth { + scroll-behavior: auto !important; +} + +.lenis.lenis-smooth [data-lenis-prevent] { + overscroll-behavior: contain; +} + +.lenis.lenis-stopped { + overflow: hidden; +} + +/* ======================== + DISPLAY TYPOGRAPHY + ======================== */ + +.font-display { + font-family: 'Space Grotesk', 'Inter', system-ui, sans-serif; +} + +.text-display-xl { + font-family: 'Space Grotesk', 'Inter', system-ui, sans-serif; + font-weight: 700; + font-size: clamp(3rem, 8vw, 7rem); + line-height: 0.95; + letter-spacing: -0.03em; +} + +.text-display-lg { + font-family: 'Space Grotesk', 'Inter', system-ui, sans-serif; + font-weight: 700; + font-size: clamp(2rem, 5vw, 4rem); + line-height: 1.05; + letter-spacing: -0.025em; +} + +.text-display-md { + font-family: 'Space Grotesk', 'Inter', system-ui, sans-serif; + font-weight: 600; + font-size: clamp(1.25rem, 3vw, 2rem); + line-height: 1.2; + letter-spacing: -0.02em; +} + +/* ======================== + EDITORIAL UTILITIES + ======================== */ + +.editorial-section { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.glass-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1.5rem; +} + +.glass-card-light { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 1.5rem; +} + +/* Gradient text utility */ +.text-gradient { + background: linear-gradient(135deg, #DC2626 0%, #F59E0B 50%, #DC2626 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Ambient glow backgrounds */ +.glow-amber { + position: relative; +} +.glow-amber::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 60%; + height: 60%; + background: radial-gradient(ellipse, rgba(245, 158, 11, 0.12) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.glow-crimson { + position: relative; +} +.glow-crimson::before { + content: ''; + position: absolute; + top: 30%; + left: 60%; + transform: translate(-50%, -50%); + width: 40%; + height: 40%; + background: radial-gradient(ellipse, rgba(220, 38, 38, 0.08) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +/* Grid overlay for hero */ +.grid-overlay { + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 80px 80px; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #E5E2DD; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #D1CCC4; +} + +[data-theme="dark"] ::-webkit-scrollbar-thumb { + background: #2A2A35; +} +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { + background: #3A3A45; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..cd510a7 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,143 @@ +const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; + +export interface Hospital { + hospital_id: string; + name: string; + eta_minutes: number; + department_match: boolean; + distance_km?: number; + status: 'pending' | 'confirmed' | 'declined'; + phone: string; +} + +export interface EmergencyResponse { + request_id: string; + hospitals: Hospital[]; + donors_alerted: number; +} + +export interface EmergencyStatusResponse { + request_id: string; + hospitals: Array<{ + hospital_id: string; + name: string; + eta_minutes: number; + status: 'pending' | 'confirmed' | 'declined'; + }>; + donors_alerted: number; + donors_responded: number; +} + +export interface DonorRegisterResponse { + ok: boolean; + donor_id: string; +} + +export interface HospitalConfirmDetailsResponse { + hospital_name: string; + emergency_type: string; + blood_group: string; + eta_minutes: number; + already_confirmed: boolean; + responded: boolean; + accepted: boolean; +} + +export interface HospitalConfirmResponse { + ok: boolean; + hospital_name: string; + already_confirmed: boolean; +} + +// Thin typed API Client for GoldenHour +export const api = { + // POST /emergency + async triggerEmergency( + lat: number, + lng: number, + emergencyType: string, + bloodGroup: string + ): Promise { + const res = await fetch(`${BASE_URL}/emergency`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + lat, + lng, + emergency_type: emergencyType, + blood_group: bloodGroup, + }), + }); + if (!res.ok) { + throw new Error(`Failed to trigger emergency: ${res.statusText}`); + } + return res.json(); + }, + + // GET /emergency/{request_id}/status + async getEmergencyStatus(requestId: string): Promise { + const res = await fetch(`${BASE_URL}/emergency/${requestId}/status`); + if (!res.ok) { + throw new Error(`Failed to fetch emergency status: ${res.statusText}`); + } + return res.json(); + }, + + // POST /donor/register + async registerDonor( + name: string, + phone: string, + bloodGroup: string, + lat: number | null, + lng: number | null, + lastDonated: string | null + ): Promise { + const res = await fetch(`${BASE_URL}/donor/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + phone, + blood_group: bloodGroup, + lat, + lng, + last_donated: lastDonated, + }), + }); + if (!res.ok) { + throw new Error(`Failed to register donor: ${res.statusText}`); + } + return res.json(); + }, + + // GET /confirm/{token} + async getHospitalConfirmDetails(token: string): Promise { + const res = await fetch(`${BASE_URL}/confirm/${token}`); + if (!res.ok) { + throw new Error(`Failed to fetch confirmation details: ${res.statusText}`); + } + return res.json(); + }, + + // POST /confirm/{token} + async confirmHospitalRequest( + token: string, + accepted: boolean + ): Promise { + const res = await fetch(`${BASE_URL}/confirm/${token}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ accepted }), + }); + if (!res.ok) { + throw new Error(`Failed to submit hospital confirmation: ${res.statusText}`); + } + return res.json(); + }, +}; diff --git a/frontend/src/lib/gsap-setup.ts b/frontend/src/lib/gsap-setup.ts new file mode 100644 index 0000000..6c0cc38 --- /dev/null +++ b/frontend/src/lib/gsap-setup.ts @@ -0,0 +1,13 @@ +import { gsap } from 'gsap'; +import { ScrollTrigger } from 'gsap/ScrollTrigger'; + +// Register GSAP plugins once at app startup +gsap.registerPlugin(ScrollTrigger); + +// Default GSAP configuration for premium feel +gsap.defaults({ + ease: 'power3.out', + duration: 1, +}); + +export { gsap, ScrollTrigger }; diff --git a/frontend/src/lib/smooth-scroll.tsx b/frontend/src/lib/smooth-scroll.tsx new file mode 100644 index 0000000..9e6082a --- /dev/null +++ b/frontend/src/lib/smooth-scroll.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; +import Lenis from 'lenis'; +import { gsap, ScrollTrigger } from './gsap-setup'; + +interface SmoothScrollContextType { + lenis: Lenis | null; +} + +const SmoothScrollContext = createContext({ lenis: null }); + +export const useLenis = () => useContext(SmoothScrollContext).lenis; + +export const SmoothScrollProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [lenis, setLenis] = useState(null); + const rafRef = useRef(0); + + useEffect(() => { + const lenisInstance = new Lenis({ + duration: 1.2, + easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), + touchMultiplier: 2, + infinite: false, + }); + + setLenis(lenisInstance); + + // Sync Lenis scroll with GSAP ScrollTrigger + lenisInstance.on('scroll', ScrollTrigger.update); + + // Use GSAP ticker for the Lenis RAF loop — keeps everything in sync + gsap.ticker.add((time: number) => { + lenisInstance.raf(time * 1000); + }); + gsap.ticker.lagSmoothing(0); + + return () => { + gsap.ticker.remove(lenisInstance.raf as any); + lenisInstance.destroy(); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts new file mode 100644 index 0000000..9fc8adf --- /dev/null +++ b/frontend/src/lib/supabase.ts @@ -0,0 +1,6 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://your-project.supabase.co'; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'dummy-key'; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index b9a1a6d..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' - -createRoot(document.getElementById('root')).render( - - - , -) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c7f1782 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import { SmoothScrollProvider } from './lib/smooth-scroll' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) + +// Register the service worker in production only — keeps dev free of SW caching. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => { + /* offline support is best-effort; ignore registration failures */ + }) + }) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index dca8ba0..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..812a203 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2022"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.ts similarity index 59% rename from frontend/vite.config.js rename to frontend/vite.config.ts index 8b0f57b..3d15f68 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss(), + ], }) diff --git a/render.yaml b/render.yaml index d57d336..bf3d97f 100644 --- a/render.yaml +++ b/render.yaml @@ -7,13 +7,26 @@ services: buildCommand: pip install -r requirements.txt startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT envVars: - - key: DATABASE_URL + # Set both SUPABASE_URL and SUPABASE_ANON_KEY to run against the real + # database; with either missing the API falls back to in-memory demo mode. + - key: SUPABASE_URL sync: false - key: SUPABASE_ANON_KEY sync: false + # Trusted server-side key (preferred for backend writes; bypasses RLS). + - key: SUPABASE_SERVICE_KEY + sync: false + - key: DATABASE_URL + sync: false - key: GOOGLE_MAPS_API_KEY sync: false - key: MSG91_AUTH_KEY sync: false + # Public URL of the deployed frontend — used for /confirm/ links + # and CORS. Update after the frontend is deployed. + - key: FRONTEND_URL + sync: false + - key: CORS_ORIGINS + sync: false - key: PYTHON_VERSION value: 3.12.0