diff --git a/backend/schemas.py b/backend/schemas.py index e471f7a..a1ab563 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -32,6 +32,8 @@ class EmergencyRequest(BaseModel): class HospitalCard(BaseModel): hospital_id: str name: str + lat: float + lng: float eta_minutes: int department_match: bool distance_km: float @@ -52,12 +54,16 @@ class EmergencyResponse(BaseModel): class HospitalStatusCard(BaseModel): hospital_id: str name: str + lat: float + lng: float eta_minutes: int status: HospitalStatus class EmergencyStatusResponse(BaseModel): request_id: str + lat: float + lng: float hospitals: List[HospitalStatusCard] donors_alerted: int donors_responded: int diff --git a/backend/services/emergency_service.py b/backend/services/emergency_service.py index a33272f..b9674a6 100644 --- a/backend/services/emergency_service.py +++ b/backend/services/emergency_service.py @@ -35,6 +35,8 @@ async def trigger_emergency(store, lat, lng, emergency_type, blood_group) -> Dic { "hospital_id": c["hospital_id"], "name": c["name"], + "lat": c["lat"], + "lng": c["lng"], "eta_minutes": c["eta_minutes"], "department_match": c["department_match"], "distance_km": c["distance_km"], @@ -115,6 +117,8 @@ def get_status(store, request_id: str) -> Dict: { "hospital_id": card["hospital_id"], "name": card["name"], + "lat": card["lat"], + "lng": card["lng"], "eta_minutes": card["eta_minutes"], "status": status, } @@ -129,6 +133,8 @@ def get_status(store, request_id: str) -> Dict: return { "request_id": request_id, + "lat": emergency["lat"], + "lng": emergency["lng"], "hospitals": hospital_cards, "donors_alerted": emergency["donors_alerted"], "donors_responded": emergency["donors_responded"], diff --git a/backend/services/hospital_service.py b/backend/services/hospital_service.py index 5a22ebb..118371a 100644 --- a/backend/services/hospital_service.py +++ b/backend/services/hospital_service.py @@ -346,27 +346,26 @@ async def rank_hospitals( candidates = candidates[:candidate_pool] - scored: List[Dict] = [] - for h in candidates: + async def get_scored_candidate(h): eta = await eta_minutes(lat, lng, h["lat"], h["lng"]) dept_match = needed_dept in h.get("departments", []) prox = _proximity_score(eta) dept = 1.0 if dept_match else 0.0 score = _score(store, h["id"], prox, dept) + return { + "hospital_id": h["id"], + "name": h["name"], + "lat": h["lat"], + "lng": h["lng"], + "eta_minutes": eta, + "department_match": dept_match, + "distance_km": h["distance_km"], + "status": "pending", + "phone": h["phone"], + "_score": score, + "_record": h, + } - scored.append( - { - "hospital_id": h["id"], - "name": h["name"], - "eta_minutes": eta, - "department_match": dept_match, - "distance_km": h["distance_km"], - "status": "pending", - "phone": h["phone"], - "_score": score, - "_record": h, - } - ) - + scored = await asyncio.gather(*(get_scored_candidate(h) for h in candidates)) scored.sort(key=lambda c: c["_score"], reverse=True) return scored[:top_n] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fa319f1..f0e0b43 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -41,6 +41,8 @@ def test_emergency_returns_contract_shape(client): assert set(card) == { "hospital_id", "name", + "lat", + "lng", "eta_minutes", "department_match", "distance_km", @@ -92,6 +94,8 @@ def test_status_shape(client): body = client.get(f"/emergency/{request_id}/status").json() assert set(body) == { "request_id", + "lat", + "lng", "hospitals", "donors_alerted", "donors_responded", @@ -101,7 +105,9 @@ def test_status_shape(client): # 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"} + assert set(card) == { + "hospital_id", "name", "lat", "lng", "eta_minutes", "status" + } # --- POST /confirm/{token} — the live flow -------------------------------- diff --git a/frontend/index.html b/frontend/index.html index 0794bb4..7a2dbbd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,12 +3,10 @@ - - - GoldenHour — Every Second Counts + GoldenHour — Emergency Hospital & Blood Response =6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -283,6 +295,29 @@ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -422,6 +457,16 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@gsap/react": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz", + "integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==", + "license": "SEE LICENSE AT https://gsap.com/standard-license", + "peerDependencies": { + "gsap": "^3.12.5", + "react": ">=17" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1231,6 +1276,49 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz", + "integrity": "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/invariant": "7.3.5", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz", + "integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -1262,6 +1350,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1269,13 +1363,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1342,13 +1445,21 @@ } } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1476,7 +1587,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1541,6 +1651,180 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", + "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-delaunay": "6", + "d3-geo": "3", + "d3-tricontour": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-tricontour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", + "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", + "license": "ISC", + "dependencies": { + "d3-delaunay": "6", + "d3-scale": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1566,6 +1850,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1576,6 +1869,12 @@ "node": ">=8" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.375", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", @@ -1626,7 +1925,6 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -1902,6 +2200,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1916,6 +2228,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/frame-ticker": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/frame-ticker/-/frame-ticker-1.0.3.tgz", + "integrity": "sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==", + "license": "MIT", + "dependencies": { + "simplesignal": "^2.1.6" + } + }, "node_modules/framer-motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", @@ -1994,6 +2315,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globe.gl": { + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/globe.gl/-/globe.gl-2.46.1.tgz", + "integrity": "sha512-h+OvX52EBIPLtM0/2JkM+JZ9gPAhPJ4y3+hxUwD5Ey/O0Zk2ockuTiJ71bZbnNBGmNiIZzA5Vr3TMT0b3d35IQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-globe": "^2.45", + "three-render-objects": "^1.41" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2007,6 +2345,17 @@ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2053,6 +2402,24 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2083,6 +2450,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2097,7 +2473,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -2147,6 +2522,18 @@ "node": ">=6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2157,6 +2544,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lenis": { "version": "1.3.23", "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz", @@ -2479,6 +2872,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2588,6 +2999,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2671,7 +3091,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2679,6 +3098,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2699,7 +3139,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2716,6 +3155,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2726,6 +3175,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2741,7 +3201,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2751,7 +3210,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2759,6 +3217,44 @@ "react": "^19.2.7" } }, + "node_modules/react-globe.gl": { + "version": "2.38.0", + "resolved": "https://registry.npmjs.org/react-globe.gl/-/react-globe.gl-2.38.0.tgz", + "integrity": "sha512-NQkMy5BGD269lkdjm9BJ2PEK81++y9H82+j4/QuWVlfXPJPTVfzaxnkC3MvaiGmpus8P6czq58OfMeYvn/4Xqg==", + "license": "MIT", + "dependencies": { + "globe.gl": "^2.46", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-kapsule": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", + "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-router": { "version": "6.30.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", @@ -2793,6 +3289,12 @@ "react-dom": ">=16.8" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2866,6 +3368,12 @@ "node": ">=8" } }, + "node_modules/simplesignal": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", + "integrity": "sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2903,6 +3411,117 @@ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "license": "MIT" }, + "node_modules/three-conic-polygon-geometry": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/three-conic-polygon-geometry/-/three-conic-polygon-geometry-2.1.3.tgz", + "integrity": "sha512-7KYcgd7rYg91L8AnCEddeyhw4qlv9+8NAI0luHq7QGt26icWUGNCsvLyD+u6FPKjP+un3zGBi4JGzqgGpmBSWA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "^7.2", + "d3-array": "1 - 3", + "d3-geo": "1 - 3", + "d3-geo-voronoi": "2", + "d3-scale": "1 - 4", + "delaunator": "5", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-geojson-geometry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/three-geojson-geometry/-/three-geojson-geometry-2.1.1.tgz", + "integrity": "sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-interpolate": "1 - 3", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-globe": { + "version": "2.45.2", + "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.2.tgz", + "integrity": "sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "d3-array": "3", + "d3-color": "3", + "d3-geo": "3", + "d3-interpolate": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "data-bind-mapper": "1", + "frame-ticker": "1", + "h3-js": "4", + "index-array-by": "1", + "kapsule": "^1.16", + "three-conic-polygon-geometry": "2", + "three-geojson-geometry": "2", + "three-slippy-map-globe": "1", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/three-render-objects": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz", + "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, + "node_modules/three-slippy-map-globe": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/three-slippy-map-globe/-/three-slippy-map-globe-1.0.6.tgz", + "integrity": "sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-octree": "^1.1", + "d3-scale": "1 - 4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2986,7 +3605,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3111,7 +3729,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 95a1508..55822f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,19 +22,23 @@ "preview": "vite preview" }, "dependencies": { + "@gsap/react": "^2.1.2", "@supabase/supabase-js": "^2.108.2", "@types/three": "^0.184.1", "framer-motion": "^12.40.0", "gsap": "^3.15.0", + "leaflet": "^1.9.4", "lenis": "^1.3.23", "lucide-react": "^1.21.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-globe.gl": "^2.38.0", "three": "^0.184.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.1", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/frontend/public/hero_ambulance.png b/frontend/public/hero_ambulance.png new file mode 100644 index 0000000..bc22825 Binary files /dev/null and b/frontend/public/hero_ambulance.png differ diff --git a/frontend/public/hero_ambulance_speed.png b/frontend/public/hero_ambulance_speed.png new file mode 100644 index 0000000..6c01586 Binary files /dev/null and b/frontend/public/hero_ambulance_speed.png differ diff --git a/frontend/public/hero_app_mockup.png b/frontend/public/hero_app_mockup.png new file mode 100644 index 0000000..bf545dd Binary files /dev/null and b/frontend/public/hero_app_mockup.png differ diff --git a/frontend/public/hero_blood_donor.png b/frontend/public/hero_blood_donor.png new file mode 100644 index 0000000..863299d Binary files /dev/null and b/frontend/public/hero_blood_donor.png differ diff --git a/frontend/public/hero_dispatch_control.png b/frontend/public/hero_dispatch_control.png new file mode 100644 index 0000000..8e6636e Binary files /dev/null and b/frontend/public/hero_dispatch_control.png differ diff --git a/frontend/public/hero_hospital_er.png b/frontend/public/hero_hospital_er.png new file mode 100644 index 0000000..6aeb1f4 Binary files /dev/null and b/frontend/public/hero_hospital_er.png differ diff --git a/frontend/public/hero_paramedics.png b/frontend/public/hero_paramedics.png new file mode 100644 index 0000000..3845837 Binary files /dev/null and b/frontend/public/hero_paramedics.png differ diff --git a/frontend/public/hero_sos_dispatch.png b/frontend/public/hero_sos_dispatch.png new file mode 100644 index 0000000..9f764f7 Binary files /dev/null and b/frontend/public/hero_sos_dispatch.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c8a459..807fd7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,16 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; +import { ReactLenis, useLenis } from 'lenis/react'; +import 'lenis/dist/lenis.css'; + 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'; +import { gsap, ScrollTrigger } from './lib/gsap-setup'; // Shared premium page entry animation wrapper const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -23,20 +27,44 @@ const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { ); }; -function AppContent() { +function AppContent({ prefersReduced }: { prefersReduced: boolean }) { const location = useLocation(); const isHome = location.pathname === '/'; const isRegister = location.pathname === '/register'; + const isResults = location.pathname.startsWith('/results/'); const isHospitalConfirm = location.pathname.startsWith('/confirm/'); - // Dark theme for home and donor registration - const theme = isHome || isRegister ? 'dark' : 'light'; + // Dark theme for home, donor registration, and results page + const theme = isHome || isRegister || isResults ? 'dark' : 'light'; + + // Sync Lenis with GSAP ScrollTrigger + const lenis = useLenis(); + + useEffect(() => { + if (!lenis || prefersReduced) return; + + const handleScroll = () => { + ScrollTrigger.update(); + }; + lenis.on('scroll', handleScroll); + + const updateRaf = (time: number) => { + lenis.raf(time * 1000); + }; + gsap.ticker.add(updateRaf); + gsap.ticker.lagSmoothing(0); + + return () => { + lenis.off('scroll', handleScroll); + gsap.ticker.remove(updateRaf); + }; + }, [lenis, prefersReduced]); return (
{ + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReduced(mediaQuery.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, []); + return ( - + + + ); } diff --git a/frontend/src/components/DonorRegistration.tsx b/frontend/src/components/DonorRegistration.tsx index ce1ea0b..bebbfec 100644 --- a/frontend/src/components/DonorRegistration.tsx +++ b/frontend/src/components/DonorRegistration.tsx @@ -6,6 +6,7 @@ import { Input } from './ui/Input'; import { Select } from './ui/Select'; import { Button } from './ui/Button'; import { api } from '../lib/api'; +import { AuroraBackground } from './ui/Effects'; export default function DonorRegistration() { const navigate = useNavigate(); @@ -138,276 +139,241 @@ export default function DonorRegistration() { const isButtonDisabled = !name.trim() || !phone.trim() || !bloodGroup; return ( -
- - {/* Top accent bar */} -
- -
- - {!isRegistered ? ( - - {/* Header / Hero */} -
-
- - Donor Network + +
+ + {/* 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 */} + setSex(e.target.value as 'male' | 'female' | '')} + options={sexOptions} + /> + + {/* GPS Coordinates Locker */} +
+ + + + + {!coords && !locating && ( +
+
-

- Become a donor. Save a life. -

-

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

-
- - {/* Divider */} -
- - - {/* 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 */} - setSex(e.target.value as 'male' | 'female' | '')} - options={sexOptions} - /> - - {/* GPS Coordinates Locker */} -
- - + {coords && ( +
+

+ Anchored: {coords.lat.toFixed(4)}, {coords.lng.toFixed(4)} +

- - {!coords && !locating && ( -
- -
- )} - - - {coords && ( - -

Location Secured

-

- Lat: {coords.lat.toFixed(4)} · Lng: {coords.lng.toFixed(4)} -

- -
- )} - - {locationError && ( - -

{locationError}

-
- - | - -
-
- )} -
- - {/* Optional Date Picker */} - setLastDonated(e.target.value)} - /> - - {/* Submit Registration Button */} - - - - ) : ( - /* Premium Success State Panel */ - - {/* Animated Ring Checkmark */} -
- -
- ✓ + )} + + {locationError && ( +
+

{locationError}

+
+ + | + +
-
- -
-

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}). -

-
- - - - )} - -
-
-
+ )} +
+ + {/* Optional Date Picker */} + 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.tsx b/frontend/src/components/HospitalConfirmPage.tsx index cb722dd..ce7117a 100644 --- a/frontend/src/components/HospitalConfirmPage.tsx +++ b/frontend/src/components/HospitalConfirmPage.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Card } from './ui/Card'; import { Button } from './ui/Button'; import { api } from '../lib/api'; +import { AuroraBackground, ShimmerSkeleton } from './ui/Effects'; export default function HospitalConfirmPage() { // Read token from URL @@ -83,52 +84,53 @@ export default function HospitalConfirmPage() { }; return ( -
- - {/* Compact Header: Brand + Status Label */} -
-
- - - - - - GoldenHour - -
+ +
-
- - - - Emergency Request + {/* Compact Header: Brand + Status Label */} +
+
+ + + + + + GoldenHour + +
+ +
+ + + + Emergency Request +
+ {!isLoading && ( +

{hospitalName}

+ )}
- {!isLoading && ( -

{hospitalName}

- )} -
- - {isLoading ? ( - - -
-
-
-
-
-
-
-
-
- - + + {isLoading ? ( + + +
+ + + +
+
+ + +
+
+
) : !isResponded ? (
-
+
+ ); } diff --git a/frontend/src/components/PatientIntakeView.tsx b/frontend/src/components/PatientIntakeView.tsx index 6554c6a..b9744d8 100644 --- a/frontend/src/components/PatientIntakeView.tsx +++ b/frontend/src/components/PatientIntakeView.tsx @@ -1,15 +1,18 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import { AnimatePresence, motion } from 'framer-motion'; +import { useNavigate } from 'react-router-dom'; +import { AnimatePresence, motion, useScroll, useTransform } 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 { TextReveal } from './motion/TextReveal'; +import { EkgSpine } from './motion/EkgSpine'; +import { HeroCinematicCarousel } from './motion/HeroCinematicCarousel'; +import { HowItWorksCinematic } from './motion/HowItWorksCinematic'; import { Button } from './ui/Button'; import { Select } from './ui/Select'; import { api } from '../lib/api'; +import { Droplet, Zap, Phone } from 'lucide-react'; + +// Cinematic slides are now managed inside HeroCinematicCarousel (SOS always first). export default function PatientIntakeView() { const navigate = useNavigate(); @@ -26,14 +29,36 @@ export default function PatientIntakeView() { // Form submission states const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const [isSceneLoaded, setIsSceneLoaded] = useState(false); + + // Dispatch transition states + const [dispatchState, setDispatchState] = useState<'idle' | 'dispatching'>('idle'); + const [currentStep, setCurrentStep] = useState(0); + const [animationFinished, setAnimationFinished] = useState(false); + const [prefersReduced, setPrefersReduced] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const heroRef = useRef(null); + + // Ref to hold resolved request_id so it can be navigated to after animation finishes + const resolvedRequestIdRef = useRef(null); + + // Carousel state now lives inside HeroCinematicCarousel component. // Refs for GSAP animations const heroTitleRef = useRef(null); const heroSubRef = useRef(null); const scrollHintRef = useRef(null); - const horizontalRef = useRef(null); - const horizontalInnerRef = useRef(null); + const intakeSectionRef = useRef(null); + const intakeLeftRef = useRef(null); + const intakeRightRef = useRef(null); + + // Parallax scroll tracking + const { scrollYProgress } = useScroll({ + target: heroRef, + offset: ["start start", "end start"] + }); + + const y = useTransform(scrollYProgress, [0, 1], [0, (prefersReduced || isMobile) ? 0 : 120]); + const darkenOpacity = useTransform(scrollYProgress, [0, 1], [0, (prefersReduced || isMobile) ? 0 : 0.6]); // Trigger Geolocation API const handleAcquireLocation = () => { @@ -69,23 +94,86 @@ export default function PatientIntakeView() { 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) { - sessionStorage.setItem(`emergency_${data.request_id}`, JSON.stringify({ - bloodGroup, - emergencyType, - rareGroup: data.rare_group ?? bloodGroup.endsWith('-') - })); - navigate(`/results/${data.request_id}`); - } else { - throw new Error('Invalid request ID returned from server.'); + resolvedRequestIdRef.current = null; + setAnimationFinished(false); + setCurrentStep(0); + setDispatchState('dispatching'); + + // Timer list to clear on cleanup/error + const activeTimers: NodeJS.Timeout[] = []; + + // Trigger API call in background + const apiCallPromise = api.triggerEmergency(coords.lat, coords.lng, emergencyType, bloodGroup) + .then((data) => { + if (data && data.request_id) { + sessionStorage.setItem(`emergency_${data.request_id}`, JSON.stringify({ + bloodGroup, + emergencyType, + rareGroup: data.rare_group ?? bloodGroup.endsWith('-') + })); + resolvedRequestIdRef.current = data.request_id; + return data.request_id; + } else { + throw new Error('Invalid request ID returned from server.'); + } + }); + + if (prefersReduced) { + // Reduced motion: navigate immediately when API call finishes + try { + const requestId = await apiCallPromise; + setDispatchState('idle'); + setIsSubmitting(false); + navigate(`/results/${requestId}`); + } catch (err: any) { + setSubmitError(err.message || 'Connection failed. Please verify that the API is online.'); + setDispatchState('idle'); + setIsSubmitting(false); + } + return; + } + + // Standard motion: orchestrate step-by-step checkmarks sequence (1.0s total) + // Step 1 checkmark: 300ms + activeTimers.push(setTimeout(() => { + setCurrentStep(1); + }, 300)); + + // Step 2 checkmark: 600ms + activeTimers.push(setTimeout(() => { + setCurrentStep(2); + }, 600)); + + // Step 3 checkmark: 900ms + activeTimers.push(setTimeout(() => { + setCurrentStep(3); + }, 900)); + + // Sequence completion check: 1000ms + activeTimers.push(setTimeout(async () => { + setAnimationFinished(true); + + // Wait for API call to complete if it hasn't already + try { + const requestId = await apiCallPromise; + setDispatchState('idle'); + setIsSubmitting(false); + navigate(`/results/${requestId}`); + } catch (err: any) { + setSubmitError(err.message || 'Connection failed. Please verify that the API is online.'); + setDispatchState('idle'); + setIsSubmitting(false); } - } catch (err: any) { + }, 1000)); + + // Watch API call concurrently to handle early failures + apiCallPromise.catch((err: any) => { + // Clear all active timers immediately on error to abort sequence + activeTimers.forEach(clearTimeout); setSubmitError(err.message || 'Connection failed. Please verify that the API is online.'); - } finally { + setDispatchState('idle'); setIsSubmitting(false); - } + }); }; const typeOptions = [ @@ -104,44 +192,40 @@ export default function PatientIntakeView() { { value: 'AB+', label: 'AB+' }, { value: 'AB-', label: 'AB-' } ]; - // === GSAP ANIMATIONS === + const stepDetails = [ + { label: 'Locking GPS', showAtStep: 0 }, + { label: 'Matching nearest hospital', showAtStep: 1 }, + { label: 'Broadcasting to donors in range', showAtStep: 2 }, + ]; - // Hero staggered title reveal + // Hero staggered title reveal — re-runs when shouldRenderGlobe settles so the + // animation fires AFTER the Suspense swap (which would otherwise trigger ctx.revert). useEffect(() => { - const ctx = gsap.context(() => { - // Title letters stagger + // Skip animation if reduced motion is preferred — chars are visible by default, + // just ensure no inline transforms are applied. + if (prefersReduced) { if (heroTitleRef.current) { - const text = heroTitleRef.current.textContent || ''; - const words = text.trim().split(' '); - heroTitleRef.current.innerHTML = ''; - - words.forEach((word, wordIdx) => { - const wordSpan = document.createElement('span'); - wordSpan.style.display = 'inline-block'; - wordSpan.style.whiteSpace = 'nowrap'; - - word.split('').forEach((char) => { - const span = document.createElement('span'); - span.textContent = char; - span.style.display = 'inline-block'; - span.style.opacity = '0'; - span.style.transform = 'translateY(100%)'; - wordSpan.appendChild(span); - }); - - heroTitleRef.current!.appendChild(wordSpan); - if (wordIdx < words.length - 1) { - heroTitleRef.current!.appendChild(document.createTextNode(' ')); - } - }); + const chars = heroTitleRef.current.querySelectorAll('.char-span'); + chars.forEach(c => { c.style.transform = 'none'; }); + } + if (heroSubRef.current) heroSubRef.current.style.opacity = '1'; + if (scrollHintRef.current) scrollHintRef.current.style.opacity = '1'; + return; + } - gsap.to(heroTitleRef.current.querySelectorAll('span > span'), { - opacity: 1, + const ctx = gsap.context(() => { + // Title letters stagger — use y transform reveal (word-spans have overflow:hidden) + // so chars slide up into view without needing opacity at all, + // preserving the parent text-gradient clip. + if (heroTitleRef.current) { + const chars = heroTitleRef.current.querySelectorAll('.char-span'); + gsap.set(chars, { y: '110%' }); + gsap.to(chars, { y: 0, - duration: 0.8, + duration: 0.7, stagger: 0.03, ease: 'power3.out', - delay: 0.3, + delay: 0.2, }); } @@ -163,93 +247,146 @@ export default function PatientIntakeView() { }); return () => ctx.revert(); - }, []); + // Re-runs when prefersReduced changes so animation respects motion preference. + }, [prefersReduced]); - // Horizontal scroll section + // Intake section scroll-driven entrance animation (3D tilt reveal) useEffect(() => { - const section = horizontalRef.current; - const inner = horizontalInnerRef.current; - if (!section || !inner) return; + if (prefersReduced) return; + + const section = intakeSectionRef.current; + const leftEl = intakeLeftRef.current; + const rightEl = intakeRightRef.current; + + if (!section || !leftEl || !rightEl) return; + + // Set perspective on the parent container to enable 3D transforms + gsap.set(section, { perspective: 1000 }); 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, + // Left description column: slide in from left and fade in + gsap.fromTo(leftEl, + { opacity: 0, x: -50 }, + { + opacity: 1, + x: 0, + scrollTrigger: { + trigger: section, + start: 'top 85%', + end: 'top 45%', + scrub: 1, + } + } + ); + + // Right dispatch console: 3D rotate entry, scale up, and slide up + gsap.fromTo(rightEl, + { + opacity: 0, + y: 100, + scale: 0.93, + transformOrigin: '50% 50%', + rotationX: 12, + rotationY: -8, }, - }); - }); + { + opacity: 1, + y: 0, + scale: 1, + rotationX: 0, + rotationY: 0, + scrollTrigger: { + trigger: section, + start: 'top 85%', + end: 'top 40%', + scrub: 1, + } + } + ); + }, section); return () => ctx.revert(); - }, []); + }, [prefersReduced]); + + const containerVariants = { + hidden: {}, + visible: { + transition: { + staggerChildren: 0.15, + } + } + }; + + const cardVariants = { + hidden: prefersReduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + ease: 'easeOut' + } + } + }; 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)} /> +
+ {/* Background Canvas: Cinematic carousel (always) + optional 3D Globe overlay on desktop */} + {/* Carousel always visible — globe renders on top for desktop/WebGL capable devices */} + + + + + + + {/* Dark Ambient Overlays (Layer 1-5 + scroll fade) - Always rendered to keep copy legible */} +
+
+
+
+
+ -
+
{/* Giant display title */}

- Every Second Counts + {(() => { + const text = "Every Second Counts"; + const words = text.split(' '); + return words.map((word, wordIdx) => ( + + {word.split('').map((char, charIdx) => ( + + {char} + + ))} + {wordIdx < words.length - 1 && ( +   + )} + + )); + })()}

{/* Subtitle */}

Smart emergency dispatch. Nearest hospital. Matched blood donors. All in real time. @@ -262,26 +399,25 @@ export default function PatientIntakeView() { transition={{ delay: 1.8, duration: 0.8 }} className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4" > - - - - + Get Help Now - - +

{/* Scroll hint */} -
+
Scroll
@@ -292,8 +428,8 @@ export default function PatientIntakeView() { {/* ============================================= SECTION 2: PROBLEM STATEMENT — Text reveals ============================================= */} -
-
+
+
+
{/* Left: Descriptive text */} - +
- Emergency Console + Live Dispatch

- Lock. Dispatch. Save. + 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. @@ -348,248 +487,179 @@ export default function PatientIntakeView() { Donor Alert

- +
{/* Right: Intake Form Card */} - -
- {/* Top accent bar */} -
- - {/* Inner content */} -
- {/* Header */} -
-
- - Live Dispatch -
-

Emergency Dispatch

-

Secure your location and select emergency details.

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

Coordinates Secured

-

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

- -
- )} - {locationError && ( - -

{locationError}

-
- - | - -
-
- )} -
-
- - {/* Selects */} - setBloodGroup(e.target.value)} options={bloodOptions} /> - - {submitError && ( -
{submitError}
- )} +
+
+ {/* Top accent */} +
+ + {/* Header */} +
+

Emergency Dispatch

+

Secure your location and select emergency details.

+
- {/* Dispatch 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 + SECTION 4: HOW IT WORKS — Cinematic 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.

-
+

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 ============================================= */} -
-
+ + {/* Full-screen Dispatching Overlay */} + + {dispatchState === 'dispatching' && ( + prefersReduced ? ( +
+
+

Dispatching...

+

Connecting with emergency response teams.

+
+
+ ) : ( + + {/* Conic sweep + Expanding rings */} +
+
+
+
+
+ +
+
+
+ +
+
+
+ + {/* Content container */} +
+ {/* Floating pulse beacon representing patient */} +
+
+
+ +
+
+ +
+

Broadcasting SOS

+

Do not close this page. Securing medical response.

+
+ + {/* Dispatch steps checklist */} +
+ {stepDetails.map((step, idx) => { + const isVisible = currentStep >= step.showAtStep; + const isCompleted = currentStep > idx; + const isActive = currentStep === idx; + + return ( +
+ + {isVisible && ( + + {/* Status Indicator */} +
+ {isCompleted ? ( + + + + + + ) : isActive ? ( +
+
+
+
+ ) : ( +
+
+
+ )} +
+ + {/* Step label */} +
+ + {step.label} + + {isActive && ( + + In Progress... + + )} +
+ + )} + +
+ ); + })} +
+
+
+ ) + )} +
+
); } diff --git a/frontend/src/components/PatientResultsView.tsx b/frontend/src/components/PatientResultsView.tsx index 2690e20..7699e17 100644 --- a/frontend/src/components/PatientResultsView.tsx +++ b/frontend/src/components/PatientResultsView.tsx @@ -1,10 +1,14 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; +import { useGSAP } from '@gsap/react'; import { Card } from './ui/Card'; -import { Badge } from './ui/Badge'; import { api } from '../lib/api'; import { supabase } from '../lib/supabase'; +import { GlassCard, CountUp, ShimmerSkeleton, AnimatedStatusBadge } from './ui/Effects'; +import { gsap, ScrollTrigger } from '../lib/gsap-setup'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; interface Hospital { hospital_id: string; @@ -13,6 +17,8 @@ interface Hospital { department_match: boolean; status: 'pending' | 'confirmed' | 'declined'; phone: string; + lat?: number; + lng?: number; } export default function PatientResultsView() { @@ -21,36 +27,191 @@ export default function PatientResultsView() { // 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: "h2", name: "Fortis Escorts 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 - const [donorsAlerted, setDonorsAlerted] = useState(0); + const [donorsAlerted] = useState(5); const [donorsResponded, setDonorsResponded] = useState(0); + // loading skeleton & error states + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + // Dynamic status states const [bloodGroup, setBloodGroup] = useState(''); const [rareGroup, setRareGroup] = useState(false); const [unconfirmedFallback, setUnconfirmedFallback] = useState(false); const [mockTimeoutSimulation, setMockTimeoutSimulation] = useState(false); + const [mockConfirmed, setMockConfirmed] = useState(false); + const [secondsRemaining, setSecondsRemaining] = useState(3512); // ~58m 32s - // loading skeleton & error states - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); + const [showSuccessBanner, setShowSuccessBanner] = useState(false); + const [confirmedHospital, setConfirmedHospital] = useState(null); + const confirmedIdsRef = useRef>(new Set()); - // Mock mode is opt-in — defaults to real API; toggle persisted in localStorage + // Coordinates + const [statusLat, setStatusLat] = useState(null); + const [statusLng, setStatusLng] = useState(null); + + // Leaflet Map Refs + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const patientMarkerRef = useRef(null); + const hospitalMarkersRef = useRef>({}); + const routeLinesRef = useRef>({}); + + // Configurable Mock state (defaults to true for reliable demo offline mode) const [isMockMode, setIsMockMode] = useState(() => { - return localStorage.getItem('goldenhour_mock_mode') === 'true'; + return localStorage.getItem('goldenhour_mock_mode') !== 'false'; }); const mountTime = useRef(Date.now()); - const isMockModeInitialized = useRef(false); + const listContainerRef = useRef(null); + + const [isMobile, setIsMobile] = useState(false); + const [prefersReduced, setPrefersReduced] = useState(false); + + const isAnyHospitalConfirmed = hospitals.some(h => h.status === 'confirmed'); - // Save toggle choice in localStorage, but skip the first render so the default - // is never baked in for users who have never explicitly toggled. useEffect(() => { - if (!isMockModeInitialized.current) { isMockModeInitialized.current = true; return; } + if (isAnyHospitalConfirmed || isLoading || hasError) return; + const timer = setInterval(() => { + setSecondsRemaining(prev => Math.max(0, prev - 1)); + }, 1000); + return () => clearInterval(timer); + }, [isAnyHospitalConfirmed, isLoading, hasError]); + + const playConfirmationChime = () => { + try { + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + + // Tone 1: E5 (659.25 Hz) + const osc1 = audioCtx.createOscillator(); + const gain1 = audioCtx.createGain(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(659.25, audioCtx.currentTime); + gain1.gain.setValueAtTime(0, audioCtx.currentTime); + gain1.gain.linearRampToValueAtTime(0.08, audioCtx.currentTime + 0.05); + gain1.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.5); + + osc1.connect(gain1); + gain1.connect(audioCtx.destination); + + // Tone 2: A5 (880.00 Hz) - slightly delayed + const osc2 = audioCtx.createOscillator(); + const gain2 = audioCtx.createGain(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(880.00, audioCtx.currentTime + 0.12); + gain2.gain.setValueAtTime(0, audioCtx.currentTime + 0.12); + gain2.gain.linearRampToValueAtTime(0.1, audioCtx.currentTime + 0.17); + gain2.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.8); + + osc2.connect(gain2); + gain2.connect(audioCtx.destination); + + osc1.start(audioCtx.currentTime); + osc1.stop(audioCtx.currentTime + 0.5); + + osc2.start(audioCtx.currentTime + 0.12); + osc2.stop(audioCtx.currentTime + 0.8); + } catch (e) { + console.error('Failed to play synthesized confirmation chime:', e); + } + }; + + useEffect(() => { + const newlyConfirmed = hospitals.find(h => h.status === 'confirmed'); + if (newlyConfirmed) { + if (!confirmedIdsRef.current.has(newlyConfirmed.hospital_id)) { + confirmedIdsRef.current.add(newlyConfirmed.hospital_id); + + // 0.0s: Freeze countdown and transition card to top + setConfirmedHospital(newlyConfirmed); + + if (!prefersReduced) { + playConfirmationChime(); + } + + const jaipurHospCoords: Record = { + "h1": [26.9036, 75.8147], + "h2": [26.8569, 75.8064], + "h3": [26.8853, 75.7470], + }; + + // 0.2s: Stagger Map pan/zoom to frame route + const mapTimer = setTimeout(() => { + const map = mapInstanceRef.current; + if (map) { + const patientLat = statusLat ?? 26.9124; + const patientLng = statusLng ?? 75.7873; + let hLat = newlyConfirmed.lat; + let hLng = newlyConfirmed.lng; + if (!hLat || !hLng) { + const coords = jaipurHospCoords[newlyConfirmed.hospital_id]; + if (coords) { + hLat = coords[0]; + hLng = coords[1]; + } + } + + if (hLat && hLng && !prefersReduced) { + const bounds = L.latLngBounds([[patientLat, patientLng], [hLat, hLng]]); + map.fitBounds(bounds, { + padding: [60, 60], + animate: true, + duration: 1.2 + }); + } + } + }, 200); + + // 0.5s: Stagger Success Banner slide-in + const bannerTimer = setTimeout(() => { + setShowSuccessBanner(true); + }, 500); + + // Auto-dismiss Success Banner after 4s (total 4.5s from confirmation) + const dismissTimer = setTimeout(() => { + setShowSuccessBanner(false); + }, 4500); + + return () => { + clearTimeout(mapTimer); + clearTimeout(bannerTimer); + clearTimeout(dismissTimer); + }; + } + } else { + confirmedIdsRef.current.clear(); + setConfirmedHospital(null); + setShowSuccessBanner(false); + } + }, [hospitals, statusLat, statusLng, prefersReduced]); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth <= 768); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReduced(mediaQuery.matches); + const motionHandler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mediaQuery.addEventListener('change', motionHandler); + + return () => { + window.removeEventListener('resize', checkMobile); + mediaQuery.removeEventListener('change', motionHandler); + }; + }, []); + + const enableStacking = !isMobile && !prefersReduced; + + // Save toggle choice in localStorage + useEffect(() => { localStorage.setItem('goldenhour_mock_mode', String(isMockMode)); }, [isMockMode]); @@ -63,17 +224,34 @@ export default function PatientResultsView() { const parsed = JSON.parse(stored); setBloodGroup(parsed.bloodGroup || ''); setRareGroup(parsed.rareGroup ?? parsed.bloodGroup?.endsWith('-') ?? false); + if (parsed.lat && parsed.lng) { + setStatusLat(parsed.lat); + setStatusLng(parsed.lng); + } } catch (e) { console.error('Failed to parse cached emergency metadata:', e); } } }, [id]); + // Normalize ETA: API contract says eta_minutes, but some rows (esp. from + // older Supabase inserts or the Google Maps path before the /60 guard was + // added) may arrive as raw seconds. If the value is >120 we treat it as + // seconds and convert. Always clamp to a realistic 1–120 min range. + const normalizeEta = (raw: number): number => { + if (raw == null || isNaN(raw)) return 5; + const asMinutes = raw > 120 ? Math.round(raw / 60) : Math.round(raw); + return Math.max(1, Math.min(120, asMinutes)); + }; + // 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); setUnconfirmedFallback(data.unconfirmed_fallback ?? false); + if (data.lat && data.lng) { + setStatusLat(data.lat); + setStatusLng(data.lng); + } if (Array.isArray(data.hospitals)) { setHospitals(prev => { return data.hospitals.map((newH: any) => { @@ -81,7 +259,7 @@ export default function PatientResultsView() { return { hospital_id: newH.hospital_id, name: newH.name, - eta_minutes: newH.eta_minutes, + eta_minutes: normalizeEta(newH.eta_minutes), status: newH.status, department_match: existing ? existing.department_match : (newH.department_match ?? false), phone: existing ? existing.phone : (newH.phone ?? "+910000000000") @@ -95,25 +273,27 @@ export default function PatientResultsView() { 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 + // After ~4s, Fortis Escorts 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, + lat: statusLat ?? 26.9124, + lng: statusLng ?? 75.7873, hospitals: mockTimeoutSimulation ? [ { hospital_id: "h1", name: "SMS Hospital", eta_minutes: 6, status: "pending" as const }, - { hospital_id: "h2", name: "Fortis Jaipur", eta_minutes: 9, status: "pending" as const }, + { hospital_id: "h2", name: "Fortis Escorts Jaipur", eta_minutes: 9, status: "pending" as const }, { hospital_id: "h3", name: "Manipal Jaipur", eta_minutes: 12, status: "pending" as const } ] : [ { 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: "h2", name: "Fortis Escorts Jaipur", eta_minutes: 9, status: (isAfter4s || mockConfirmed) ? "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 && !mockTimeoutSimulation ? 2 : 0, + donors_responded: (isAfter4s || mockConfirmed) && !mockTimeoutSimulation ? 2 : 0, unconfirmed_fallback: mockTimeoutSimulation }; @@ -153,7 +333,7 @@ export default function PatientResultsView() { clearInterval(intervalId); clearTimeout(skeletonTimer); }; - }, [id, isMockMode, mockTimeoutSimulation]); + }, [id, isMockMode]); // Supabase Realtime subscription (live socket push changes) useEffect(() => { @@ -213,7 +393,7 @@ export default function PatientResultsView() { return { hospital_id: newH.hospital_id, name: newH.name, - eta_minutes: newH.eta_minutes, + eta_minutes: normalizeEta(newH.eta_minutes), status: newH.status, department_match: existing ? existing.department_match : (newH.department_match ?? false), phone: existing ? existing.phone : (newH.phone ?? "+910000000000") @@ -243,267 +423,773 @@ export default function PatientResultsView() { return a.eta_minutes - b.eta_minutes; }); - return ( -
- - {/* Top Status Header */} -
-
- {/* Pulsing indicator */} - - - - - Finding help… + const dependencyKey = sortedHospitals.map(h => `${h.hospital_id}-${h.status}`).join(','); + + useGSAP(() => { + if (!enableStacking) return; + + // Clean up any existing card-stacking ScrollTriggers first to avoid collisions + ScrollTrigger.getAll().forEach(trigger => { + if (trigger.vars.id === 'card-stacking') { + trigger.kill(); + } + }); + + const cards = gsap.utils.toArray('.hospital-card-wrapper'); + if (cards.length === 0) return; + + cards.forEach((card, index) => { + // The last card doesn't need to scale down/fade out since nothing stacks on top of it + if (index === cards.length - 1) return; + + const innerCard = card.querySelector('.shadow-layered') || card.firstElementChild; + if (!innerCard) return; + + gsap.to(innerCard, { + scale: 0.92, + opacity: 0.5, + boxShadow: '0 25px 50px -12px rgba(26, 23, 20, 0.25)', + transformOrigin: 'top center', + ease: 'none', + scrollTrigger: { + id: 'card-stacking', + trigger: card, + start: `top top+=${80 + index * 16}`, + end: `bottom top+=${80 + index * 16}`, + scrub: true, + invalidateOnRefresh: true, + } + }); + }); + + ScrollTrigger.refresh(); + }, { scope: listContainerRef, dependencies: [dependencyKey, enableStacking, isLoading, hasError] }); + + // 1. Initialize Map instance + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + const patientLat = statusLat ?? 26.9124; + const patientLng = statusLng ?? 75.7873; + + // Create Map + const map = L.map(mapRef.current, { + zoomControl: true, + attributionControl: false, + }).setView([patientLat, patientLng], 13); + + // Dark vector tiles matching dark/glassmorphic aesthetics + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 20, + }).addTo(map); + + mapInstanceRef.current = map; + + return () => { + if (mapInstanceRef.current) { + mapInstanceRef.current.remove(); + mapInstanceRef.current = null; + } + routeLinesRef.current = {}; + hospitalMarkersRef.current = {}; + patientMarkerRef.current = null; + }; + }, [statusLat, statusLng]); + + // 2. Synchronize Map Markers & Radar Ping Animation + useEffect(() => { + const map = mapInstanceRef.current; + if (!map) return; + + const patientLat = statusLat ?? 26.9124; + const patientLng = statusLng ?? 75.7873; + + // Check if a hospital is confirmed + const isAnyHospitalConfirmed = hospitals.some(h => h.status === 'confirmed'); + + // Dynamically update patient marker and radar ping + if (patientMarkerRef.current) { + patientMarkerRef.current.remove(); + } + + const pingClass = isAnyHospitalConfirmed ? 'radar-ping-paused' : 'radar-ping-ring'; + + let htmlContent = ''; + if (prefersReduced) { + // Static range circle for prefers-reduced-motion fallback + htmlContent = ` +
+
+
-
- {isMockMode && ( - - )} - - - ID: {id} - + `; + } else { + // Concentric crimson rings repeating every 2.2 seconds (defined in index.css) + htmlContent = ` +
+
+
+
+
+
+
-
+ `; + } - {/* Warning Banners (Rare blood / Timeout Fallback) */} -
- {rareGroup && ( - - ⚠️ -
-

Rare Blood Group Requested

-

- Compatible donors for blood group {bloodGroup || 'Rh-negative'} are scarce. Alerts have been broadcast, but supply may be limited. -

+ const patientIcon = L.divIcon({ + className: 'custom-patient-icon', + html: htmlContent, + iconSize: [40, 40], + iconAnchor: [20, 20], + }); + + patientMarkerRef.current = L.marker([patientLat, patientLng], { icon: patientIcon }) + .addTo(map) + .bindPopup('Patient Location (SOS)', { closeButton: false }); + + // Center map view on patient location + map.setView([patientLat, patientLng], map.getZoom()); + + // Sync Hospital Markers & Route Lines + // Clean up markers for hospitals no longer present + Object.keys(hospitalMarkersRef.current).forEach(hid => { + if (!hospitals.some(h => h.hospital_id === hid)) { + hospitalMarkersRef.current[hid].remove(); + delete hospitalMarkersRef.current[hid]; + } + }); + + Object.keys(routeLinesRef.current).forEach(hid => { + if (!hospitals.some(h => h.hospital_id === hid)) { + routeLinesRef.current[hid].remove(); + delete routeLinesRef.current[hid]; + } + }); + + // Seeded coordinates map for local Jaipur mock database fallback + const jaipurHospCoords: Record = { + "h1": [26.9036, 75.8147], // SMS Hospital + "h2": [26.8569, 75.8064], // Fortis Escorts Jaipur (Fortis Jaipur) + "h3": [26.8853, 75.7470], // Manipal Hospital Jaipur (Manipal Jaipur) + }; + + hospitals.forEach(h => { + let hLat = h.lat; + let hLng = h.lng; + + if (!hLat || !hLng) { + const coords = jaipurHospCoords[h.hospital_id]; + if (coords) { + hLat = coords[0]; + hLng = coords[1]; + } + } + + if (hLat && hLng) { + let markerColor = 'bg-amber-500'; + let ringClass = 'border-amber-500/35'; + let statusLabel = 'Pending response'; + + if (h.status === 'confirmed') { + markerColor = 'bg-emerald-500 scale-110'; + ringClass = prefersReduced + ? 'border-emerald-500/40' + : 'border-emerald-500/40 animate-ping'; + statusLabel = 'Confirmed Bed Available'; + } else if (h.status === 'declined') { + markerColor = 'bg-slate-400 opacity-60'; + ringClass = 'border-slate-400/10'; + statusLabel = 'Decline / Unavailable'; + } + + // Draw/Update Polyline Route + if (h.status === 'confirmed') { + // 1. Background solid line + if (routeLinesRef.current[h.hospital_id]) { + routeLinesRef.current[h.hospital_id].remove(); + } + const polyline = L.polyline([[patientLat, patientLng], [hLat, hLng]], { + color: '#10B981', + weight: 5, + opacity: 0.9, + className: prefersReduced ? 'route-line-confirmed-static' : 'route-line-confirmed' + }).addTo(map); + routeLinesRef.current[h.hospital_id] = polyline; + + // 2. Animated traveling pulse overlay line + const pulseKey = `${h.hospital_id}_pulse`; + if (routeLinesRef.current[pulseKey]) { + routeLinesRef.current[pulseKey].remove(); + } + if (!prefersReduced) { + const pulsePolyline = L.polyline([[patientLat, patientLng], [hLat, hLng]], { + color: '#34D399', + weight: 5, + opacity: 0.95, + dashArray: '10, 15', + className: 'route-line-flow' + }).addTo(map); + routeLinesRef.current[pulseKey] = pulsePolyline; + } + } else { + // Clean up old pulse lines if present + const pulseKey = `${h.hospital_id}_pulse`; + if (routeLinesRef.current[pulseKey]) { + routeLinesRef.current[pulseKey].remove(); + delete routeLinesRef.current[pulseKey]; + } + + let polylineOptions: L.PolylineOptions = {}; + if (h.status === 'declined') { + polylineOptions = { + color: '#94A3B8', + weight: 2, + opacity: 0.25, + className: 'route-line-declined' + }; + } else { + polylineOptions = { + color: '#F59E0B', + weight: 3, + opacity: 0.65, + dashArray: '8, 8', + className: 'route-line-pending' + }; + } + + if (routeLinesRef.current[h.hospital_id]) { + routeLinesRef.current[h.hospital_id].remove(); + } + const polyline = L.polyline([[patientLat, patientLng], [hLat, hLng]], polylineOptions).addTo(map); + routeLinesRef.current[h.hospital_id] = polyline; + } + + const hospitalIcon = L.divIcon({ + className: 'custom-hospital-icon', + html: ` +
+
+
+ H +
- - )} + `, + iconSize: [30, 30], + iconAnchor: [15, 15], + }); - {unconfirmedFallback && ( - - 🚨 -
-

Response Timeout Fallback

-

- No hospital has confirmed the request yet. We strongly recommend contacting the nearest hospital directly using the call links below. -

+ if (hospitalMarkersRef.current[h.hospital_id]) { + hospitalMarkersRef.current[h.hospital_id] + .setLatLng([hLat, hLng]) + .setIcon(hospitalIcon); + } else { + const marker = L.marker([hLat, hLng], { icon: hospitalIcon }) + .addTo(map) + .bindPopup(` +
+

${h.name}

+

${statusLabel}

+

${h.eta_minutes} min ETA

+
+ `, { closeButton: false }); + hospitalMarkersRef.current[h.hospital_id] = marker; + } + } + }); + }, [statusLat, statusLng, hospitals, prefersReduced]); + + const handleShowDirections = (hospital: Hospital) => { + const map = mapInstanceRef.current; + if (map) { + const patientLat = statusLat ?? 26.9124; + const patientLng = statusLng ?? 75.7873; + + let hLat = hospital.lat; + let hLng = hospital.lng; + + const jaipurHospCoords: Record = { + "h1": [26.9036, 75.8147], + "h2": [26.8569, 75.8064], + "h3": [26.8853, 75.7470], + }; + + if (!hLat || !hLng) { + const coords = jaipurHospCoords[hospital.hospital_id]; + if (coords) { + hLat = coords[0]; + hLng = coords[1]; + } + } + + if (hLat && hLng) { + const bounds = L.latLngBounds([[patientLat, patientLng], [hLat, hLng]]); + map.fitBounds(bounds, { + padding: [80, 80], + animate: !prefersReduced, + duration: prefersReduced ? 0 : 1.0 + }); + + const marker = hospitalMarkersRef.current[hospital.hospital_id]; + if (marker) { + marker.openPopup(); + } + } + } + + if (isMobile && mapRef.current) { + mapRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }; + + const formatTime = (secs: number) => { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${s < 10 ? '0' : ''}${s}`; + }; + + return ( +
+
+ + {/* Left Column: Interactive Map & Donor Panel Console */} +
+
+
+ + {/* Overlay branding banner on map */} +
+
+ + Live SOS Map +
- - )} -
- - {/* Hospital Cards (AnimatePresence for layout transition animations) */} -
- - {isLoading ? ( - - {[1, 2, 3].map((idx) => ( -
-
-
-
-
-
-
-
-
-
+
+ + {/* Donor Panel */} + +
+ + + + + donors alerted nearby + + + {donorsResponded > 0 ? ( + <> + responded + + ) : ( + 'Waiting for responses' + )} + +
+ + {/* Blood drop placeholder badge */} +
+ + + +
+
+
+ + {/* Right Column: Hospital list & Status */} +
+ {/* Top Status Header with Golden Hour Progress Ring */} +
+
+ {/* SVG Countdown Ring */} +
+ + + + +
+ {isAnyHospitalConfirmed ? ( + + + + ) : ( + + )}
- ))} - - ) : hasError ? ( - -
- !
-

Connection Issue

-

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

- -
- ) : sortedHospitals.length === 0 ? ( - -
- ? + + {/* Countdown text / Label */} +
+

+ {isAnyHospitalConfirmed ? ( + Hospital Confirmed + ) : ( + Golden Hour Countdown + )} +

+

+ {isAnyHospitalConfirmed ? ( + "Emergency dispatch established" + ) : ( + <> + Time remaining: {formatTime(secondsRemaining)} + + )} +

-

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 ( - - + +
+ + {isAnyHospitalConfirmed ? 'SECURED' : 'MONITORING'} + + + {import.meta.env.DEV && isMockMode && ( +
+ - -
+ +
+ )} +
+
- {/* Badging: ETA */} -
- - - {h.eta_minutes} min ETA - -
+ {/* Warning Banners (Rare blood / Timeout Fallback) */} +
+ {rareGroup && ( + + ⚠️ +
+

Rare Blood Group Requested

+

+ Compatible donors for blood group {bloodGroup || 'Rh-negative'} are scarce. Alerts have been broadcast, but supply may be limited. +

+
+
+ )} - {/* 1-tap Call Button */} - + 🚨 +
+

Response Timeout Fallback

+

+ No hospital has confirmed the request yet. We strongly recommend contacting the nearest hospital directly using the call links below. +

+
+ + )} +
+ + {/* Hospital Cards (AnimatePresence for layout transition animations) */} + - - {/* Pinned Bottom Donor Panel */} -
-
-
- - - - - {isLoading ? 'Locating donors nearby…' : `${donorsAlerted} donors alerted nearby`} - - - {donorsResponded > 0 ? `${donorsResponded} responded` : 'Waiting for responses'} - -
+ ) : 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 && ( +
+ )} + + {/* Visual Sweep overlay effect on confirm */} + {isConfirmed && !prefersReduced && ( + + )} + +
+
+

+ {h.name} +

+ + {h.department_match ? ( + + Dept ✓ matched + + ) : ( + + Dept mismatch + + )} +
+ + +
+ + {/* Badging: ETA */} +
+ + + + +
+ + {/* 1-tap Actions: Call & Directions */} +
+ + + {isConfirmed ? `Call ${h.name}` : 'Call dispatch'} + - {/* Blood drop placeholder badge */} -
- - - + +
+ + + ); + }) + )} +
-
-
+
{/* Right Column */} +
{/* Grid */} + + {/* Success Banner */} + + {showSuccessBanner && confirmedHospital && ( + +
+ {/* Glow effect */} +
+ + {/* Checkmark Circle */} +
+ + + +
+ +
+

SOS Confirmed

+

+ {confirmedHospital.name} confirmed — they're expecting you. +

+
+ + {/* Dismiss Button */} + +
+ + )} +
); } diff --git a/frontend/src/components/motion/CountUp.tsx b/frontend/src/components/motion/CountUp.tsx index 4b9c7a8..f741747 100644 --- a/frontend/src/components/motion/CountUp.tsx +++ b/frontend/src/components/motion/CountUp.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState } from 'react'; -import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; +import { useInView, animate, useMotionValue, useReducedMotion } from 'framer-motion'; interface CountUpProps { end: number; @@ -12,52 +12,44 @@ interface CountUpProps { /** * Animated number counter that counts from 0 to target value - * when scrolled into view via GSAP ScrollTrigger. + * when scrolled into view via Framer Motion's useInView. */ export const CountUp: React.FC = ({ end, prefix = '', suffix = '', - duration = 2, + duration = 0.5, className = '', decimals = 0, }) => { const ref = useRef(null); - const [hasAnimated, setHasAnimated] = useState(false); + const isInView = useInView(ref, { once: true, margin: "-10% 0px" }); + const count = useMotionValue(0); + const [display, setDisplay] = useState(0); + const prefersReduced = useReducedMotion(); useEffect(() => { - const el = ref.current; - if (!el || hasAnimated) return; + if (!isInView) return; - const counter = { value: 0 }; + if (prefersReduced) { + setDisplay(end); + return; + } - 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}`; - }, - }); - }, + const controls = animate(count, end, { + duration, + ease: 'easeOut', + onUpdate: (latest) => { + setDisplay(latest); + } }); - return () => { - ScrollTrigger.getAll().forEach(st => { - if (st.trigger === el) st.kill(); - }); - }; - }, [end, prefix, suffix, duration, decimals, hasAnimated]); + return () => controls.stop(); + }, [isInView, end, duration, prefersReduced]); return ( - {prefix}0{suffix} + {prefix}{display.toFixed(decimals)}{suffix} ); }; diff --git a/frontend/src/components/motion/EkgSpine.tsx b/frontend/src/components/motion/EkgSpine.tsx new file mode 100644 index 0000000..163ac72 --- /dev/null +++ b/frontend/src/components/motion/EkgSpine.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; + +interface EkgSpineProps {} + +const getEkgPath = (height: number, type: 'normal' | 'flatline' | 'revived') => { + const cycleHeight = 120; + // Generate a little bit of extra padding to ensure the path goes fully offscreen + const numCycles = Math.ceil((height + 100) / cycleHeight); + let d = 'M 24 0'; + + const relativePoints = [ + { y: 0.0, normal: 24, flatline: 24, revived: 24 }, + { y: 0.15, normal: 24, flatline: 24, revived: 24 }, + { y: 0.20, normal: 28, flatline: 24, revived: 34 }, + { y: 0.25, normal: 24, flatline: 24, revived: 24 }, + { y: 0.35, normal: 24, flatline: 24, revived: 24 }, + { y: 0.38, normal: 20, flatline: 24, revived: 14 }, + { y: 0.42, normal: 42, flatline: 24, revived: 46 }, + { y: 0.46, normal: 8, flatline: 24, revived: 2 }, + { y: 0.50, normal: 24, flatline: 24, revived: 24 }, + { y: 0.60, normal: 24, flatline: 24, revived: 24 }, + { y: 0.66, normal: 31, flatline: 24, revived: 38 }, + { y: 0.72, normal: 24, flatline: 24, revived: 24 }, + { y: 0.85, normal: 24, flatline: 24, revived: 24 }, + { y: 0.90, normal: 25, flatline: 24, revived: 32 }, + { y: 1.0, normal: 24, flatline: 24, revived: 24 }, + ]; + + for (let i = 0; i < numCycles; i++) { + const startY = i * cycleHeight; + relativePoints.forEach((pt) => { + const yVal = startY + pt.y * cycleHeight; + let xVal = 24; + if (type === 'normal') { + xVal = pt.normal; + } else if (type === 'flatline') { + xVal = pt.flatline; + } else if (type === 'revived') { + xVal = pt.revived; + } + + if (i === 0 && pt.y === 0.0) return; + d += ` L ${xVal.toFixed(1)} ${yVal.toFixed(1)}`; + }); + } + return d; +}; + +export const EkgSpine: React.FC = () => { + const [height, setHeight] = useState(800); + const [prefersReduced, setPrefersReduced] = useState(false); + const pathRef = useRef(null); + const pulseRef = useRef(null); + const trailRefs = useRef<(SVGCircleElement | null)[]>([]); + const activeState = useRef<'normal' | 'flatline' | 'revived'>('normal'); + const pulseAnimRef = useRef(null); + + // Read window height & handle resize + useEffect(() => { + const handleResize = () => { + setHeight(window.innerHeight); + }; + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Detect prefers-reduced-motion + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReduced(mediaQuery.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, []); + + const normalD = getEkgPath(height, 'normal'); + const flatlineD = getEkgPath(height, 'flatline'); + const revivedD = getEkgPath(height, 'revived'); + + // Align path immediately when height changes (to avoid resize jumps) + useEffect(() => { + if (pathRef.current) { + let targetD = normalD; + if (activeState.current === 'flatline') targetD = flatlineD; + else if (activeState.current === 'revived') targetD = revivedD; + gsap.set(pathRef.current, { attr: { d: targetD } }); + } + }, [height, normalD, flatlineD, revivedD]); + + // Set up animations and ScrollTriggers + useEffect(() => { + if (prefersReduced) return; + + const transitionToState = (state: 'normal' | 'flatline' | 'revived') => { + activeState.current = state; + let targetD = normalD; + let targetTimeScale = 1.0; + + if (state === 'flatline') { + targetD = flatlineD; + targetTimeScale = 0.15; + } else if (state === 'revived') { + targetD = revivedD; + targetTimeScale = 1.6; + } + + // Tween path shape morph + if (pathRef.current) { + gsap.to(pathRef.current, { + attr: { d: targetD }, + duration: 0.8, + ease: 'power2.inOut', + overwrite: 'auto', + }); + } + + // Tween pulse speed + if (pulseAnimRef.current) { + gsap.to(pulseAnimRef.current, { + timeScale: targetTimeScale, + duration: 0.8, + ease: 'power2.out', + overwrite: 'auto', + }); + } + }; + + // looping pulse animation (60bpm default) + const progressObj = { value: 0 }; + const pulseAnim = gsap.to(progressObj, { + value: 1, + duration: 1, + ease: 'none', + repeat: -1, + onUpdate: () => { + if (!pathRef.current) return; + const path = pathRef.current; + const totalLength = path.getTotalLength(); + if (totalLength === 0) return; + + // main pulse circle + const p0 = path.getPointAtLength(progressObj.value * totalLength); + if (pulseRef.current) { + pulseRef.current.setAttribute('cx', p0.x.toString()); + pulseRef.current.setAttribute('cy', p0.y.toString()); + } + + // trail circles + for (let i = 0; i < 3; i++) { + const trailRef = trailRefs.current[i]; + if (trailRef) { + let trailProgress = progressObj.value - (i + 1) * 0.015; + if (trailProgress < 0) trailProgress += 1.0; + const pt = path.getPointAtLength(trailProgress * totalLength); + trailRef.setAttribute('cx', pt.x.toString()); + trailRef.setAttribute('cy', pt.y.toString()); + } + } + }, + }); + pulseAnimRef.current = pulseAnim; + + // ScrollTrigger 1: Problem Statement Section + const trigger1 = ScrollTrigger.create({ + trigger: '#problem-statement', + start: 'top 75%', + end: 'bottom 25%', + onEnter: () => transitionToState('flatline'), + onEnterBack: () => transitionToState('flatline'), + onLeave: () => transitionToState('normal'), + onLeaveBack: () => transitionToState('normal'), + }); + + // ScrollTrigger 2: CTA Footer Section + const trigger2 = ScrollTrigger.create({ + trigger: '#cta-footer', + start: 'top 85%', + end: 'bottom bottom', + onEnter: () => transitionToState('revived'), + onLeaveBack: () => transitionToState('normal'), + }); + + // Initial check in case starting scrolled down + ScrollTrigger.refresh(); + + return () => { + pulseAnim.kill(); + trigger1.kill(); + trigger2.kill(); + }; + }, [height, normalD, flatlineD, revivedD, prefersReduced]); + + if (prefersReduced) { + return ( +
+ + + +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + {/* Subtle background rail line */} + + + {/* Main EKG Line */} + + + {/* Pulse Circle */} + + + {/* Trail Circles */} + { trailRefs.current[0] = el; }} + r="3" + fill="#DC2626" + opacity="0.6" + filter="url(#ekg-glow)" + /> + { trailRefs.current[1] = el; }} + r="2" + fill="#DC2626" + opacity="0.3" + /> + { trailRefs.current[2] = el; }} + r="1.5" + fill="#DC2626" + opacity="0.15" + /> + +
+ ); +}; diff --git a/frontend/src/components/motion/HeroCinematicCarousel.tsx b/frontend/src/components/motion/HeroCinematicCarousel.tsx new file mode 100644 index 0000000..214c02c --- /dev/null +++ b/frontend/src/components/motion/HeroCinematicCarousel.tsx @@ -0,0 +1,524 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +/* ───────────────────────────────────────────────────────────── + Slide definitions — SOS is always first. + Each slide carries its own motion-effect type so we can + render a purpose-built animated overlay on top of the photo. +───────────────────────────────────────────────────────────── */ +type SlideEffect = + | 'sos-pulse' + | 'ambulance-rush' + | 'paramedic-rush' + | 'blood-pulse' + | 'er-arrival' + | 'dispatch-grid'; + +interface Slide { + src: string; + effect: SlideEffect; + label: string; + caption: string; + kenBurns: 'zoom-in' | 'zoom-in-left' | 'zoom-in-right' | 'pan-left' | 'pan-right'; +} + +const SLIDES: Slide[] = [ + { + src: '/hero_sos_dispatch.png', + effect: 'sos-pulse', + label: 'SOS', + caption: 'One tap. Every second counts.', + kenBurns: 'zoom-in', + }, + { + src: '/hero_ambulance_speed.png', + effect: 'ambulance-rush', + label: 'DISPATCH', + caption: 'Nearest ambulance. Instant dispatch.', + kenBurns: 'pan-left', + }, + { + src: '/hero_paramedics.png', + effect: 'paramedic-rush', + label: 'RESPONSE', + caption: 'Trained paramedics. En route.', + kenBurns: 'zoom-in-right', + }, + { + src: '/hero_blood_donor.png', + effect: 'blood-pulse', + label: 'DONORS', + caption: 'Matched blood donors alerted.', + kenBurns: 'zoom-in-left', + }, + { + src: '/hero_hospital_er.png', + effect: 'er-arrival', + label: 'ARRIVAL', + caption: 'Hospital ready. Care begins.', + kenBurns: 'pan-right', + }, + { + src: '/hero_dispatch_control.png', + effect: 'dispatch-grid', + label: 'NETWORK', + caption: '24/7 live dispatch grid.', + kenBurns: 'zoom-in', + }, +]; + +/* ───────────────────────────────────────────────────────────── + Ken-Burns CSS keyframes injected once +───────────────────────────────────────────────────────────── */ +const kenBurnsCSS = ` +@keyframes kb-zoom-in { + from { transform: scale(1.12) translate(0, 0); } + to { transform: scale(1.0) translate(0, 0); } +} +@keyframes kb-zoom-in-left { + from { transform: scale(1.12) translateX(2%); } + to { transform: scale(1.0) translateX(0%); } +} +@keyframes kb-zoom-in-right { + from { transform: scale(1.12) translateX(-2%); } + to { transform: scale(1.0) translateX(0%); } +} +@keyframes kb-pan-left { + from { transform: scale(1.06) translateX(3%); } + to { transform: scale(1.06) translateX(-3%); } +} +@keyframes kb-pan-right { + from { transform: scale(1.06) translateX(-3%); } + to { transform: scale(1.06) translateX(3%); } +} +@keyframes ambulance-light-red { + 0%, 45%, 55%, 100% { opacity: 0; } + 50% { opacity: 0.45; } +} +@keyframes ambulance-light-blue { + 0%, 45%, 55%, 100% { opacity: 0; } + 50% { opacity: 0.4; } +} +@keyframes speed-streak { + 0% { transform: translateX(110%); opacity: 0.7; } + 100% { transform: translateX(-110%); opacity: 0; } +} +@keyframes rain-drop { + 0% { transform: translateY(-60px) translateX(0); opacity: 0.6; } + 100% { transform: translateY(110vh) translateX(-15px); opacity: 0; } +} +@keyframes sos-ring { + 0% { transform: scale(0.3); opacity: 0.9; } + 100% { transform: scale(3.5); opacity: 0; } +} +@keyframes sos-center-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.12); opacity: 0.85; } +} +@keyframes blood-drip { + 0% { transform: scaleY(0); opacity: 1; transform-origin: top; } + 60% { transform: scaleY(1); opacity: 1; transform-origin: top; } + 100% { transform: scaleY(1); opacity: 0; transform-origin: top; } +} +@keyframes hud-scan { + 0%, 100% { clip-path: inset(0 0 100% 0); } + 50% { clip-path: inset(0 0 0% 0); } +} +@keyframes grid-dot-blink { + 0%, 100% { opacity: 0.15; } + 50% { opacity: 0.9; } +} +@keyframes ekg-draw { + from { stroke-dashoffset: 800; } + to { stroke-dashoffset: 0; } +} +`; + +/* ───────────────────────────────────────────────────────────── + Per-slide motion overlay components +───────────────────────────────────────────────────────────── */ + +/** SOS — concentric pulsing red rings + central glow dot */ +function SosPulseOverlay() { + return ( +
+ {/* Radial crimson glow */} +
+ {/* Pulsing rings */} + {[0, 0.8, 1.6, 2.4].map((delay, i) => ( +
+ ))} + {/* EKG line at bottom */} + + + +
+ ); +} + +/** Ambulance Rush — speed streaks + alternating red/blue siren flash */ +function AmbulanceRushOverlay() { + const streaks = Array.from({ length: 12 }, (_, i) => ({ + top: `${8 + i * 7.5}%`, + width: `${30 + Math.random() * 45}%`, + delay: `${(i * 0.18).toFixed(2)}s`, + duration: `${(0.6 + Math.random() * 0.5).toFixed(2)}s`, + opacity: 0.25 + Math.random() * 0.35, + height: i % 3 === 0 ? 3 : i % 3 === 1 ? 1.5 : 2, + })); + + return ( +
+ {/* Red siren flash — left side */} +
+ {/* Blue siren flash — right side */} +
+ {/* Horizontal speed streaks */} + {streaks.map((s, i) => ( +
+ ))} + {/* Vignette motion blur sides */} +
+
+ ); +} + +/** Paramedic Rush — motion blur + camera shake feel */ +function ParamedicRushOverlay() { + return ( +
+ {/* Red emergency strobe */} +
+ {/* Vertical speed streaks for corridor rushing effect */} + {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} + {/* Bottom crimson glow */} +
+
+ ); +} + +/** Blood Pulse — slow pulsing crimson glow + dripping line */ +function BloodPulseOverlay() { + return ( +
+ {/* Slow crimson heartbeat glow */} +
+ {/* EKG heart-rate line */} + + + +
+ ); +} + +/** ER Arrival — rain drops + flashing red from ambulance */ +function ErArrivalOverlay() { + const drops = Array.from({ length: 30 }, (_, i) => ({ + left: `${Math.random() * 100}%`, + delay: `${(Math.random() * 2).toFixed(2)}s`, + duration: `${(0.6 + Math.random() * 0.8).toFixed(2)}s`, + height: `${50 + Math.floor(Math.random() * 80)}px`, + opacity: 0.15 + Math.random() * 0.3, + })); + + return ( +
+ {/* Rain drops */} + {drops.map((d, i) => ( +
+ ))} + {/* Alternating red/blue siren from arriving ambulance */} +
+
+
+ ); +} + +/** Dispatch Grid — scanning grid + blinking location nodes */ +function DispatchGridOverlay() { + const nodes = Array.from({ length: 8 }, (_, i) => ({ + left: `${15 + i * 10 + Math.sin(i) * 5}%`, + top: `${20 + Math.cos(i * 1.3) * 25 + 30}%`, + delay: `${(i * 0.3).toFixed(1)}s`, + color: i % 3 === 0 ? '#DC2626' : i % 3 === 1 ? '#F59E0B' : '#14B8A6', + })); + + return ( +
+ {/* Subtle scan line */} +
+ {/* Grid dots */} + {nodes.map((n, i) => ( +
+ ))} + {/* Connection lines SVG */} + + + + + + + +
+ ); +} + +function renderOverlay(effect: SlideEffect) { + switch (effect) { + case 'sos-pulse': return ; + case 'ambulance-rush': return ; + case 'paramedic-rush': return ; + case 'blood-pulse': return ; + case 'er-arrival': return ; + case 'dispatch-grid': return ; + default: return null; + } +} + +const KB_ANIMATION: Record = { + 'zoom-in': 'kb-zoom-in 8s ease-out forwards', + 'zoom-in-left': 'kb-zoom-in-left 8s ease-out forwards', + 'zoom-in-right': 'kb-zoom-in-right 8s ease-out forwards', + 'pan-left': 'kb-pan-left 8s linear forwards', + 'pan-right': 'kb-pan-right 8s linear forwards', +}; + +/* ───────────────────────────────────────────────────────────── + Progress bar dots +───────────────────────────────────────────────────────────── */ +function SlideDots({ total, current, onSelect }: { total: number; current: number; onSelect: (i: number) => void }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ ); +} + +/* ───────────────────────────────────────────────────────────── + Slide label badge +───────────────────────────────────────────────────────────── */ +function SlideBadge({ label, caption }: { label: string; caption: string }) { + return ( + +
+ + {label} +
+

{caption}

+
+ ); +} + +/* ───────────────────────────────────────────────────────────── + Main exported component +───────────────────────────────────────────────────────────── */ +export function HeroCinematicCarousel({ style, motionY }: { + style?: React.CSSProperties; + motionY?: any; // framer-motion MotionValue +}) { + const [current, setCurrent] = useState(0); + const [paused, setPaused] = useState(false); + const timerRef = useRef | null>(null); + + // Inject Ken Burns keyframes once + useEffect(() => { + if (document.getElementById('kb-styles')) return; + const style = document.createElement('style'); + style.id = 'kb-styles'; + style.textContent = kenBurnsCSS; + document.head.appendChild(style); + return () => { style.remove(); }; + }, []); + + const advance = useCallback(() => { + setCurrent(c => (c + 1) % SLIDES.length); + }, []); + + useEffect(() => { + if (paused) return; + timerRef.current = setInterval(advance, 7000); + return () => { if (timerRef.current) clearInterval(timerRef.current); }; + }, [advance, paused]); + + const goTo = (i: number) => { + setCurrent(i); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(advance, 7000); + }; + + const slide = SLIDES[current]; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + > + {/* ── Crossfade between slides ── */} + + + {/* Background photo with Ken Burns */} +
+ + {/* Per-slide live motion overlay */} + {renderOverlay(slide.effect)} + + + + {/* ── Permanent UI chrome ── */} + + + + + +
+ ); +} diff --git a/frontend/src/components/motion/HeroGlobe.tsx b/frontend/src/components/motion/HeroGlobe.tsx new file mode 100644 index 0000000..ab49ad9 --- /dev/null +++ b/frontend/src/components/motion/HeroGlobe.tsx @@ -0,0 +1,240 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Globe from 'react-globe.gl'; +import * as THREE from 'three'; + +interface GlobePoint { + name: string; + lat: number; + lng: number; + type: 'hospital' | 'donor' | 'patient'; + color: string; + size: number; +} + +interface GlobeArc { + startLat: number; + startLng: number; + endLat: number; + endLng: number; + color: string | string[]; +} + +const CITIES: GlobePoint[] = [ + { name: 'Jaipur', lat: 26.9124, lng: 75.7873, type: 'patient', color: '#DC2626', size: 0.15 }, + { name: 'Mumbai', lat: 19.0760, lng: 72.8777, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Delhi', lat: 28.6139, lng: 77.2090, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Bengaluru', lat: 12.9716, lng: 77.5946, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Chennai', lat: 13.0827, lng: 80.2707, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Kolkata', lat: 22.5726, lng: 88.3639, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Hyderabad', lat: 17.3850, lng: 78.4867, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Ahmedabad', lat: 23.0225, lng: 72.5714, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Pune', lat: 18.5204, lng: 73.8567, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Surat', lat: 21.1702, lng: 72.8311, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Lucknow', lat: 26.8467, lng: 80.9462, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Patna', lat: 25.5941, lng: 85.1376, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Bhopal', lat: 23.2599, lng: 77.4126, type: 'hospital', color: '#00F0FF', size: 0.08 }, + { name: 'Guwahati', lat: 26.1158, lng: 91.7086, type: 'donor', color: '#F59E0B', size: 0.06 }, + { name: 'Kochi', lat: 9.9312, lng: 76.2673, type: 'donor', color: '#F59E0B', size: 0.06 } +]; + +const RINGS_DATA = [ + { + lat: 26.9124, + lng: 75.7873, // Jaipur + maxRadius: 12, + propagationSpeed: 2.8, + repeatNum: 3 + } +]; + +export default function HeroGlobe() { + const globeRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); + const [inView, setInView] = useState(true); + const [tilt, setTilt] = useState({ x: 0, y: 0 }); + const targetTilt = useRef({ x: 0, y: 0 }); + const [activeArcs, setActiveArcs] = useState([]); + + // 1. Measure and update container dimensions + useEffect(() => { + if (!containerRef.current) return; + const updateSize = () => { + if (containerRef.current) { + setDimensions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight + }); + } + }; + updateSize(); + + const observer = new ResizeObserver(updateSize); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // 2. Track element visibility in viewport + useEffect(() => { + if (!containerRef.current) return; + const observer = new IntersectionObserver(([entry]) => { + setInView(entry.isIntersecting); + }, { threshold: 0.05 }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // 3. Mouse Parallax Tilt Lerping + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const { innerWidth, innerHeight } = window; + const x = (e.clientX - innerWidth / 2) / (innerWidth / 2); // -1 to 1 + const y = (e.clientY - innerHeight / 2) / (innerHeight / 2); // -1 to 1 + targetTilt.current = { x: x * 8, y: -y * 8 }; // Max 8 degrees tilt + }; + + window.addEventListener('mousemove', handleMouseMove); + + let animId: number; + const update = () => { + setTilt(prev => ({ + x: prev.x + (targetTilt.current.x - prev.x) * 0.06, // Smooth lerp + y: prev.y + (targetTilt.current.y - prev.y) * 0.06 + })); + animId = requestAnimationFrame(update); + }; + update(); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + cancelAnimationFrame(animId); + }; + }, []); + + // 4. Real-time spawner for dispatch arcs + useEffect(() => { + // Generate initial set of arcs converging on Jaipur + const nonPatients = CITIES.filter(c => c.type !== 'patient'); + const initialArcs = nonPatients.slice(0, 5).map(c => ({ + startLat: c.lat, + startLng: c.lng, + endLat: 26.9124, + endLng: 75.7873, + color: ['#DC2626', '#F59E0B'] + })); + setActiveArcs(initialArcs); + + const interval = setInterval(() => { + const randomCity = nonPatients[Math.floor(Math.random() * nonPatients.length)]; + const newArc = { + startLat: randomCity.lat, + startLng: randomCity.lng, + endLat: 26.9124, + endLng: 75.7873, + color: ['#DC2626', '#F59E0B'] + }; + + setActiveArcs(prev => { + const next = [...prev, newArc]; + // Cap active arcs to keep visual tidy and performant + if (next.length > 7) { + next.shift(); + } + return next; + }); + }, 2200); + + return () => clearInterval(interval); + }, []); + + // 5. Setup OrbitControls and focus India + useEffect(() => { + if (globeRef.current) { + globeRef.current.pointOfView({ lat: 20.5937, lng: 78.9629, altitude: 2.2 }, 0); + + const controls = globeRef.current.controls(); + if (controls) { + controls.enableZoom = false; + controls.enableRotate = false; + controls.enablePan = false; + controls.autoRotate = inView; + controls.autoRotateSpeed = 0.5; // Auto rotate slowly + } + + const renderer = globeRef.current.renderer(); + if (renderer) { + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap DPR + } + } + }, [dimensions, inView]); + + // Dark near-black / deep-crimson Phong material for moody feel + const customMaterial = new THREE.MeshPhongMaterial({ + color: 0x070404, + emissive: 0x030101, + specular: 0x110303, + shininess: 6 + }); + + return ( +
+
+ (t: number) => `rgba(220, 38, 38, ${1 - t})`} + ringMaxRadius="maxRadius" + ringPropagationSpeed="propagationSpeed" + ringRepeatNum="repeatNum" + /> +
+
+ ); +} diff --git a/frontend/src/components/motion/HowItWorksCinematic.tsx b/frontend/src/components/motion/HowItWorksCinematic.tsx new file mode 100644 index 0000000..d9c3381 --- /dev/null +++ b/frontend/src/components/motion/HowItWorksCinematic.tsx @@ -0,0 +1,531 @@ +/** + * HowItWorksCinematic + * + * Pinned, scroll-scrubbed cinematic sequence for the "How It Works" section. + * + * Desktop / no-reduced-motion: + * - ScrollTrigger pins this section for 300% of viewport height. + * - A MM:SS HUD counts 60:00 → 00:00 mapped to scroll progress, tinting crimson near 0. + * - Three step cards slide horizontally (translateX) as the user scrolls. + * - A blood-drop radial wipe flourish plays between each card transition. + * + * Mobile (≤768 px) or prefers-reduced-motion: + * - No pinning, no horizontal scrub. + * - Cards render as a plain stacked/grid layout. + * - Countdown shown as a static "60:00" badge. + * + * Sync: + * - ScrollTrigger is already synced to Lenis via `smooth-scroll.tsx` and `App.tsx` + * (lenis.on('scroll', ScrollTrigger.update) + gsap.ticker.add(lenis.raf)). + * - useGSAP with a scoped sectionRef handles auto-cleanup on unmount/re-render. + * - ScrollTrigger.refresh() is called on resize (debounced 200 ms) and after load. + */ + +import React, { useRef, useEffect, useState } from 'react'; +import { useGSAP } from '@gsap/react'; +import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; +import { MapPin, Building2, Droplet } from 'lucide-react'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface StepCard { + step: string; + title: string; + description: string; + icon: React.ReactNode; + accentColor: string; + index: number; +} + +// ─── Step data ──────────────────────────────────────────────────────────────── + +const STEPS: StepCard[] = [ + { + step: 'Step 01', + title: 'Lock Location', + description: + 'Your browser GPS pins your exact coordinates. High-accuracy mode ensures precision even in dense urban areas — no manual entry, no delay.', + icon: , + accentColor: '#F59E0B', + index: 0, + }, + { + step: 'Step 02', + title: 'Smart Dispatch', + description: + 'Our algorithm matches your emergency type to the nearest hospital with the right department and available bed capacity — in milliseconds.', + icon: , + accentColor: '#DC2626', + index: 1, + }, + { + step: 'Step 03', + title: 'Donor Alert', + description: + 'Every registered blood donor matching your blood type within range receives an instant push alert with your location — simultaneously.', + icon: , + accentColor: '#059669', + index: 2, + }, +]; + +// ─── Countdown formatter ────────────────────────────────────────────────────── + +function formatCountdown(progress: number): string { + // progress 0→1 → 60:00 → 00:00 + const totalSeconds = Math.round((1 - Math.min(1, progress)) * 3600); + const mm = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const ss = (totalSeconds % 60).toString().padStart(2, '0'); + return `${mm}:${ss}`; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function HowItWorksCinematic() { + // ── Media query state ──────────────────────────────────────────────────────── + const [prefersReduced, setPrefersReduced] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }); + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false; + return window.innerWidth <= 768; + }); + + useEffect(() => { + const mqMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const onMotion = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mqMotion.addEventListener('change', onMotion); + + const mqWidth = window.matchMedia('(max-width: 768px)'); + const onWidth = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mqWidth.addEventListener('change', onWidth); + + return () => { + mqMotion.removeEventListener('change', onMotion); + mqWidth.removeEventListener('change', onWidth); + }; + }, []); + + // Cinematic mode = desktop && no reduced-motion preference + const cinematic = !prefersReduced && !isMobile; + + // ── Refs ───────────────────────────────────────────────────────────────────── + const sectionRef = useRef(null); + const trackRef = useRef(null); + const hudLabelRef = useRef(null); // MM:SS text + const hudClockRef = useRef(null); // wrapper for color + const wipe0Ref = useRef(null); // wipe between card 0→1 + const wipe1Ref = useRef(null); // wipe between card 1→2 + const card0Ref = useRef(null); + const card1Ref = useRef(null); + const card2Ref = useRef(null); + + // ── GSAP (cinematic only, scoped to sectionRef) ─────────────────────────── + useGSAP( + () => { + if (!cinematic) return; + + const section = sectionRef.current; + const track = trackRef.current; + const label = hudLabelRef.current; + const clock = hudClockRef.current; + const wipe0 = wipe0Ref.current; + const wipe1 = wipe1Ref.current; + const card0 = card0Ref.current; + const card1 = card1Ref.current; + const card2 = card2Ref.current; + + if (!section || !track || !label || !clock || !wipe0 || !wipe1 || !card0 || !card1 || !card2) return; + + // ── Starting states ──────────────────────────────────────────────────── + // Card 0 starts active; cards 1 & 2 start dimmed/small + gsap.set([card1, card2], { scale: 0.88, opacity: 0.3, filter: 'blur(4px)' }); + gsap.set(card0, { scale: 1, opacity: 1, filter: 'blur(0px)' }); + gsap.set([wipe0, wipe1], { + clipPath: 'circle(0% at 50% 50%)', + opacity: 0, + }); + + // ── Helper: how far to slide the track ──────────────────────────────── + // Now that track is relative, offsetLeft is stable and relative to track. + // We return -target so the active card is perfectly centered. + const getSlideForCard = (cardEl: HTMLDivElement) => { + const sectionW = section.offsetWidth; + const cardCenter = cardEl.offsetLeft + cardEl.offsetWidth / 2; + const target = cardCenter - sectionW / 2; + return -target; + }; + + // Set initial centered state for card0 + gsap.set(track, { x: () => getSlideForCard(card0) }); + + // ── Main scrubbed timeline ──────────────────────────────────────────── + const tl = gsap.timeline({ paused: true }); + + // Segment 0 → 0.45: slide track from card 0 to card 1 + tl + .fromTo(track, + { x: () => getSlideForCard(card0) }, + { + x: () => getSlideForCard(card1), + ease: 'none', + duration: 0.45, + }, + 0 + ) + // Card 0 dims out + .to(card0, { scale: 0.88, opacity: 0.3, filter: 'blur(3px)', ease: 'power2.in', duration: 0.18 }, 0.22) + // Wipe 0→1 flourish at ~30 % of timeline + .to(wipe0, { clipPath: 'circle(80% at 50% 50%)', opacity: 1, duration: 0.08, ease: 'power4.out' }, 0.29) + .to(wipe0, { clipPath: 'circle(0% at 50% 50%)', opacity: 0, duration: 0.08, ease: 'power4.in' }, 0.38) + // Card 1 scales up + .to(card1, { scale: 1, opacity: 1, filter: 'blur(0px)', ease: 'power2.out', duration: 0.15 }, 0.33) + + // Segment 0.5 → 1.0: slide track so card 2 is centered + .to(track, { + x: () => getSlideForCard(card2), + ease: 'none', + duration: 0.5, + }, 0.5) + // Card 1 dims + .to(card1, { scale: 0.88, opacity: 0.3, filter: 'blur(3px)', ease: 'power2.in', duration: 0.18 }, 0.66) + // Wipe 1→2 flourish at ~75 % + .to(wipe1, { clipPath: 'circle(80% at 50% 50%)', opacity: 1, duration: 0.08, ease: 'power4.out' }, 0.73) + .to(wipe1, { clipPath: 'circle(0% at 50% 50%)', opacity: 0, duration: 0.08, ease: 'power4.in' }, 0.82) + // Card 2 scales up + .to(card2, { scale: 1, opacity: 1, filter: 'blur(0px)', ease: 'power2.out', duration: 0.15 }, 0.77); + + // ── ScrollTrigger (pins section, scrubs timeline) ───────────────────── + ScrollTrigger.create({ + trigger: section, + start: 'top top', + end: '+=300%', + pin: true, + scrub: 1.2, + anticipatePin: 1, + invalidateOnRefresh: true, + animation: tl, + onUpdate(self) { + const p = self.progress; + + // ── Update countdown HUD ───────────────────────────────────────── + label.textContent = formatCountdown(p); + + // ── Tint clock: goldenhour → crimson ───────────────────────────── + let r: number, g: number, b: number, glow: string; + if (p >= 0.85) { + r = 220; g = 38; b = 38; + glow = 'rgba(220,38,38,0.7)'; + } else if (p >= 0.6) { + const t = (p - 0.6) / 0.25; + r = Math.round(245 + (220 - 245) * t); + g = Math.round(158 + (38 - 158) * t); + b = Math.round(11 + (38 - 11 ) * t); + glow = `rgba(220,38,38,${(t * 0.5).toFixed(2)})`; + } else { + r = 245; g = 158; b = 11; + glow = 'rgba(245,158,11,0.4)'; + } + clock.style.color = `rgb(${r},${g},${b})`; + clock.style.textShadow = `0 0 30px ${glow}`; + }, + }); + + // ── Resize → refresh (debounced) ────────────────────────────────────── + let timer: ReturnType; + const onResize = () => { + clearTimeout(timer); + timer = setTimeout(() => ScrollTrigger.refresh(), 200); + }; + window.addEventListener('resize', onResize, { passive: true }); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', onResize); + }; + }, + { scope: sectionRef, dependencies: [cinematic] }, + ); + + // ── After fonts / images load, re-measure ──────────────────────────────── + useEffect(() => { + if (!cinematic) return; + const refresh = () => ScrollTrigger.refresh(); + if (document.readyState === 'complete') { + requestAnimationFrame(refresh); + } else { + window.addEventListener('load', refresh, { once: true }); + return () => window.removeEventListener('load', refresh); + } + }, [cinematic]); + + // ────────────────────────────────────────────────────────────────────────── + // STATIC FALLBACK (mobile / reduced-motion) + // ────────────────────────────────────────────────────────────────────────── + if (!cinematic) { + return ( +
+
+

+ How It Works +

+

+ Three steps.{' '} + Zero delay. +

+ {/* Static countdown badge */} +
+ + 60:00 + Golden Hour Countdown +
+
+ +
+ {STEPS.map((step) => ( + + ))} +
+
+ ); + } + + // ────────────────────────────────────────────────────────────────────────── + // CINEMATIC DESKTOP LAYOUT + // ────────────────────────────────────────────────────────────────────────── + return ( +
+ {/* ── Ambient background glows ─────────────────────────────────────── */} +
+
+ + {/* ── Section heading ───────────────────────────────────────────────── */} +
+
+

+ How It Works +

+

+ Three steps.{' '} + Zero delay. +

+
+
+ + {/* ── Countdown HUD ─────────────────────────────────────────────────── */} +
+
+ + Golden Hour + + {/* Big clock — ref'd for live color + text updates */} +
+ 60:00 +
+ + Remaining + +
+
+
+ + {/* ── Horizontal card track + wipe overlays ─────────────────────────── */} +
+ {/* Left & right fade edges */} +
+
+ + {/* Blood-drop wipe overlays — cover the full track viewport, triggered by GSAP */} +
+
+ + {/* The sliding track */} +
+ + + +
+
+ + {/* ── Scroll cue ────────────────────────────────────────────────────── */} +
+ + Scroll to continue + +
+
+
+
+
+ ); +} + +// ─── CinematicStepCard ──────────────────────────────────────────────────────── + +function CinematicStepCard({ + step, + cardRef, +}: { + step: StepCard; + cardRef: React.RefObject; +}) { + return ( +
+ {/* Accent top bar */} +
+ + {/* Number watermark */} +
+ {String(step.index + 1).padStart(2, '0')} +
+ + {/* Content */} +
+
+ {step.icon} +
+

+ {step.step} +

+

+ {step.title} +

+

+ {step.description} +

+
+ + {/* Bottom accent bar */} +
+
+ ); +} + +// ─── StaticStepCard ─────────────────────────────────────────────────────────── + +function StaticStepCard({ step }: { step: StepCard }) { + return ( +
+
+
+
+ {step.icon} +
+

+ {step.step} +

+

{step.title}

+

{step.description}

+
+
+
+ ); +} diff --git a/frontend/src/components/motion/TextReveal.tsx b/frontend/src/components/motion/TextReveal.tsx index f8f1273..e4f2d8b 100644 --- a/frontend/src/components/motion/TextReveal.tsx +++ b/frontend/src/components/motion/TextReveal.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { gsap, ScrollTrigger } from '../../lib/gsap-setup'; +import { useReducedMotion } from 'framer-motion'; interface TextRevealProps { children: string; @@ -28,8 +29,11 @@ export const TextReveal: React.FC = ({ end = 'top 25%', }) => { const containerRef = useRef(null); + const prefersReduced = useReducedMotion(); useEffect(() => { + if (prefersReduced) return; + const el = containerRef.current; if (!el) return; diff --git a/frontend/src/components/ui/AppBar.tsx b/frontend/src/components/ui/AppBar.tsx index 22e7c60..612ef1d 100644 --- a/frontend/src/components/ui/AppBar.tsx +++ b/frontend/src/components/ui/AppBar.tsx @@ -5,6 +5,9 @@ import { gsap } from '../../lib/gsap-setup'; export const AppBar: React.FC = () => { const location = useLocation(); const isHome = location.pathname === '/'; + const isRegister = location.pathname === '/register'; + const isResults = location.pathname.startsWith('/results/'); + const isDark = isHome || isRegister || isResults; const headerRef = useRef(null); const [hidden, setHidden] = useState(false); const lastScrollY = useRef(0); @@ -44,8 +47,8 @@ export const AppBar: React.FC = () => {
@@ -54,15 +57,15 @@ export const AppBar: React.FC = () => {
{/* Heartbeat pulse rings */} {
GoldenHour @@ -99,10 +102,10 @@ export const AppBar: React.FC = () => { : 'bg-success/10 border border-success/20' }`}> - + - Console Active + Live
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index f5b1360..e3fc02f 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,54 +1,104 @@ import React from 'react'; import { motion } from 'framer-motion'; +import { Link } from 'react-router-dom'; -export interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'emergency' | 'primary' | 'success' | 'ghost'; +const MotionLink = motion(Link); + +export interface ButtonProps extends Omit, 'onClick'> { + variant?: 'emergency' | 'secondary' | 'success' | 'ghost' | 'primary'; + size?: 'md' | 'lg'; isLoading?: boolean; fullWidth?: boolean; + href?: string; + to?: string; + onClick?: (e: React.MouseEvent) => void; } export const Button: React.FC = ({ children, variant = 'primary', + size = 'md', isLoading = false, fullWidth = false, + href, + to, className = '', disabled, + onClick, + type = 'button', ...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 baseStyle = "relative flex items-center justify-center font-extrabold tracking-wider uppercase transition-all duration-300 focus:outline-none focus-visible:ring-4 disabled:opacity-50 disabled:cursor-not-allowed select-none cursor-pointer"; + + const sizeStyles = { + md: "h-14 px-10 text-sm rounded-2xl", + lg: "h-16 px-12 text-base rounded-2xl" + }; 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" + emergency: "bg-gradient-to-r from-emergency to-emergency-pressed text-white shadow-lg shadow-emergency/25 animate-pulse-glow hover:scale-105 active:scale-[0.98] border-0", + secondary: "border-2 border-goldenhour text-goldenhour hover:bg-goldenhour/10 bg-transparent hover:scale-105 active:scale-[0.98]", + 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)] hover:scale-105 active:scale-[0.98]", + 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)] hover:scale-105 active:scale-[0.98]", + 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 hover:scale-105 active:scale-[0.98]" }; + const combinedClassName = `${baseStyle} ${sizeStyles[size]} ${widthStyle} ${variantStyles[variant]} ${className}`; + + const content = isLoading ? ( + + + + + + Processing... + + ) : ( + children + ); + + const tapAnimation = disabled || isLoading ? {} : { whileTap: { scale: 0.96 } }; + + if (to) { + return ( + + {content} + + ); + } + + if (href) { + return ( + + {content} + + ); + } + return ( - {isLoading ? ( - - {/* Spinner icon */} - - - - - Processing... - - ) : ( - children - )} + {content} ); }; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx index 13b8b29..83d4d8c 100644 --- a/frontend/src/components/ui/Card.tsx +++ b/frontend/src/components/ui/Card.tsx @@ -13,7 +13,7 @@ export const Card: React.FC = ({ className = '', ...props }) => { - const cardStyle = `bg-white rounded-2xl p-5 shadow-layered border border-slate-100/30 overflow-hidden relative ${className}`; + const cardStyle = `bg-surface rounded-2xl p-5 shadow-layered border border-slate-100/30 dark:border-white/5 overflow-hidden relative ${className}`; if (!animateEntrance) { return ( diff --git a/frontend/src/components/ui/CustomCursor.tsx b/frontend/src/components/ui/CustomCursor.tsx index ef3b7bc..455b907 100644 --- a/frontend/src/components/ui/CustomCursor.tsx +++ b/frontend/src/components/ui/CustomCursor.tsx @@ -23,8 +23,8 @@ export const CustomCursor: React.FC = () => { const ring = ringRef.current; if (!dot || !ring) return; - // Set initial offscreen positions - gsap.set([dot, ring], { xPercent: -50, yPercent: -50 }); + // Set initial offscreen positions (prevents flash at 0,0 before first mousemove) + gsap.set([dot, ring], { x: -9999, y: -9999, 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' }); @@ -194,10 +194,10 @@ export const CustomCursor: React.FC = () => { willChange: 'transform, width, height, border-radius, background-color', }} /> - {/* Inner Pin Dot */} + {/* Inner Pin Dot — white + mix-blend-difference so it reads on any background */}
= ({ + children, + className = "", +}) => { + return ( +
+ {children} +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* 2. CountUp — animates a number up to `to` */ +/* ------------------------------------------------------------------ */ +interface CountUpProps { + to: number; + duration?: number; + prefix?: string; + suffix?: string; + className?: string; +} + +export const CountUp: React.FC = ({ + to = 0, + duration = 1, + prefix = "", + suffix = "", + className = "", +}) => { + const reduce = useReducedMotion(); + const count = useMotionValue(0); + const [display, setDisplay] = useState(0); + + useEffect(() => { + if (reduce) { + setDisplay(Math.round(to)); + return; + } + const controls = animate(count, to, { duration, ease: "easeOut" }); + const unsub = count.on("change", (v) => setDisplay(Math.round(v))); + return () => { + controls.stop(); + unsub(); + }; + }, [to, duration, reduce]); + + return ( + + {prefix} + {display} + {suffix} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 3. ShimmerSkeleton — loading placeholder with a light sweep */ +/* ------------------------------------------------------------------ */ +export const ShimmerSkeleton: React.FC<{ className?: string }> = ({ + className = "h-4 w-full rounded-lg", +}) => { + const reduce = useReducedMotion(); + return ( +
+ {!reduce && ( + + )} +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* 4. AnimatedStatusBadge — pending / confirmed / declined */ +/* ------------------------------------------------------------------ */ +const STATUS_STYLES = { + pending: { + label: "Pending", + wrap: "bg-amber-100 text-amber-900 border border-amber-300 shadow-amber-500/10", + dot: "bg-amber-500 shadow-[0_0_8px_#f59e0b]" + }, + confirmed: { + label: "Confirmed", + wrap: "bg-emerald-100 text-emerald-950 border border-emerald-300 shadow-emerald-500/10", + dot: "bg-emerald-600 shadow-[0_0_8px_#10b981]" + }, + declined: { + label: "Declined", + wrap: "bg-slate-100/60 text-slate-400 border border-slate-200/60", + dot: "bg-slate-300" + }, +}; + +interface AnimatedStatusBadgeProps { + status?: "pending" | "confirmed" | "declined"; +} + +export const AnimatedStatusBadge: React.FC = ({ status = "pending" }) => { + const reduce = useReducedMotion(); + const s = STATUS_STYLES[status] ?? STATUS_STYLES.pending; + + return ( + + + + {status === "confirmed" && !reduce && ( + + )} + + + {s.label} + + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 5. AuroraBackground — soft drifting crimson/emerald/amber blobs */ +/* ------------------------------------------------------------------ */ +interface BlobProps { + className: string; + color: string; + anim: any; + duration: number; +} + +const Blob: React.FC = ({ className, color, anim, duration }) => { + const reduce = useReducedMotion(); + return ( + + ); +}; + +export const AuroraBackground: React.FC<{ children: React.ReactNode; className?: string }> = ({ + children, + className = "bg-white", +}) => { + return ( +
+
+ + + +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx index 8f0d2a0..a5f35ec 100644 --- a/frontend/src/components/ui/Select.tsx +++ b/frontend/src/components/ui/Select.tsx @@ -1,34 +1,38 @@ -import React, { useId, useState, useRef, useEffect } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; +import React, { useState, useEffect, useRef, useId } from 'react'; +import { ChevronDown } from 'lucide-react'; -export interface SelectProps { +export interface SelectProps extends Omit, 'value' | 'onChange'> { label: string; - value: string; - onChange: (e: { target: { value: string } }) => void; options: { value: string; label: string }[]; error?: string; - className?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; } export const Select: React.FC = ({ label, - value, - onChange, options, error, className = '', + value = '', + onChange, + disabled, + placeholder, + ...props }) => { const id = useId(); const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const containerRef = useRef(null); + const buttonRef = useRef(null); - const selectedOption = options.find(opt => opt.value === value); - const displayLabel = selectedOption ? selectedOption.label : options[0]?.label; + // Find the label of the currently selected option + const selectedOption = options.find((opt) => opt.value === value); + const displayLabel = selectedOption ? selectedOption.label : placeholder || options[0]?.label || ''; - // Close dropdown when clicking outside + // Close when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); } }; @@ -36,75 +40,109 @@ export const Select: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + const handleSelect = (val: string) => { + if (disabled) return; + setIsOpen(false); + + // Trigger synthetic change event to maintain full compatibility with native select onChange logic + if (onChange) { + const mockEvent = { + target: { + value: val, + name: props.name || '', + }, + } as unknown as React.ChangeEvent; + onChange(mockEvent); + } + + // Focus back on the button for keyboard accessibility + buttonRef.current?.focus(); + }; + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setIsOpen(true); + } + }; + + const handleListKeyDown = (e: React.KeyboardEvent, val: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(val); + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + buttonRef.current?.focus(); + } + }; + return ( -
+
- - {isOpen && ( - -
- {options.map((opt) => ( - - ))} -
-
- )} -
+ {/* Dropdown Options List */} + {isOpen && ( +
+ {options.map((opt) => { + const isSelected = opt.value === value; + return ( +
handleSelect(opt.value)} + onKeyDown={(e) => handleListKeyDown(e, opt.value)} + className={`px-4 py-3 text-sm font-bold cursor-pointer transition-all hover:bg-slate-100 focus:bg-slate-100 focus:outline-none + dark:hover:bg-white/5 dark:focus:bg-white/5 + ${isSelected ? 'text-[#DC2626] bg-slate-50 dark:bg-white/5 dark:text-[#DC2626]' : ''}`} + > + {opt.label} +
+ ); + })} +
+ )}
{error && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 32c7ee9..0939de2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,6 +1,8 @@ @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"; +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + @theme { --font-sans: 'Inter', system-ui, sans-serif; --font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif; @@ -10,10 +12,10 @@ --color-ink: #1A1714; --color-ink-muted: #6B6560; - --color-dark-bg: #14141A; - --color-dark-surface: #1E1E2A; + --color-dark-bg: #0A0A0F; + --color-dark-surface: #14141F; --color-dark-ink: #F0EDE8; - --color-dark-ink-muted: #A09B95; + --color-dark-ink-muted: #B0ACA7; --color-emergency: #DC2626; --color-emergency-pressed: #B91C1C; @@ -35,20 +37,61 @@ --color-success: #059669; --color-pending: #9CA3AF; --color-goldenhour: #F59E0B; + --color-border-sweep-start: rgba(226, 232, 240, 0.6); } /* Dark theme for home/hero */ [data-theme="dark"] { - --color-bg: #14141A; - --color-surface: #1E1E2A; + --color-bg: #0A0A0F; + --color-surface: #14141F; --color-ink: #F0EDE8; - --color-ink-muted: #A09B95; + --color-ink-muted: #B0ACA7; + --color-border-sweep-start: rgba(255, 255, 255, 0.08); } /* ======================== KEYFRAMES ======================== */ +@keyframes radar-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes radar-pulse-ring { + 0% { transform: scale(0.4); opacity: 0.6; } + 100% { transform: scale(2.2); opacity: 0; } +} + +.animate-radar-rotate { + animation: radar-rotate 4s linear infinite; +} + +.animate-radar-ring { + animation: radar-pulse-ring 3s cubic-bezier(0.1, 0.8, 0.3, 1) infinite; +} + +@keyframes live-ping { + 0% { + transform: scale(1); + opacity: 1; + } + 75%, 100% { + transform: scale(2.2); + opacity: 0; + } +} + +.animate-live-ping { + animation: live-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .animate-live-ping { + animation: none !important; + } +} + @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)); } @@ -229,7 +272,7 @@ html.lenis, html.lenis body { transform: translate(-50%, -50%); width: 60%; height: 60%; - background: radial-gradient(ellipse, rgba(245, 158, 11, 0.2) 0%, transparent 70%); + background: radial-gradient(ellipse, rgba(245, 158, 11, 0.12) 0%, transparent 70%); pointer-events: none; z-index: 0; } @@ -245,7 +288,7 @@ html.lenis, html.lenis body { transform: translate(-50%, -50%); width: 40%; height: 40%; - background: radial-gradient(ellipse, rgba(220, 38, 38, 0.15) 0%, transparent 70%); + background: radial-gradient(ellipse, rgba(220, 38, 38, 0.08) 0%, transparent 70%); pointer-events: none; z-index: 0; } @@ -253,8 +296,8 @@ html.lenis, html.lenis body { /* Grid overlay for hero */ .grid-overlay { background-image: - linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + 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; } @@ -280,3 +323,151 @@ html.lenis, html.lenis body { [data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #3A3A45; } + +/* =========================================================================== + LIVE RESULTS MAP RADAR PING ANIMATION + =========================================================================== */ +@keyframes ping-expand { + 0% { + transform: scale(0.3); + opacity: 0.85; + } + 50% { + opacity: 0.4; + } + 100% { + transform: scale(3.5); + opacity: 0; + } +} + +.radar-ping-ring { + animation: ping-expand 2.2s cubic-bezier(0.1, 0.8, 0.3, 1) infinite; +} + +.radar-ping-paused { + animation-play-state: paused !important; + display: none !important; +} + +.radar-ping-static-circle { + border: 1.5px solid rgba(220, 38, 38, 0.4); + background-color: rgba(220, 38, 38, 0.06); + border-radius: 9999px; + width: 140px; + height: 140px; +} + +/* =========================================================================== + LEAFLET MAP OVERRIDES + =========================================================================== */ +.leaflet-popup-content-wrapper { + background: rgba(10, 10, 15, 0.96) !important; + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #FFFFFF !important; + border-radius: 14px !important; + box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.6) !important; +} + +.leaflet-popup-tip { + background: rgba(10, 10, 15, 0.96) !important; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.leaflet-container { + font-family: 'Inter', sans-serif !important; +} + +/* =========================================================================== + HOSPITAL CONFIRMED BEAT ANIMATIONS + =========================================================================== */ + +@keyframes card-emerald-sweep { + 0% { + background-color: var(--color-surface); + border-color: var(--color-border-sweep-start); + box-shadow: 0 1px 3px rgba(26, 23, 20, 0.02), 0 8px 24px rgba(26, 23, 20, 0.04); + } + 30% { + background-color: #10b981; + border-color: #10b981; + box-shadow: 0 10px 25px -5px rgba(16, 185, 129, 0.4); + color: #ffffff; + } + 100% { + background-color: rgba(16, 185, 129, 0.08); + border-color: rgba(16, 185, 129, 0.4); + box-shadow: 0 4px 20px rgba(5, 150, 105, 0.08); + } +} + +.confirmed-card-sweep { + animation: card-emerald-sweep 1.5s cubic-bezier(0.25, 1, 0.5, 1) forwards; +} + +@keyframes route-pulse { + 0% { + stroke-width: 5; + stroke-opacity: 0.9; + filter: drop-shadow(0 0 0px rgba(16, 185, 129, 0)); + } + 50% { + stroke-width: 10; + stroke-opacity: 1; + filter: drop-shadow(0 0 12px rgba(16, 185, 129, 0.95)); + } + 100% { + stroke-width: 5; + stroke-opacity: 0.9; + filter: drop-shadow(0 0 0px rgba(16, 185, 129, 0)); + } +} + +.route-line-confirmed { + stroke-dasharray: none !important; + animation: route-pulse 1.5s cubic-bezier(0.25, 1, 0.5, 1) 1; +} + +.route-line-confirmed-static { + stroke-dasharray: none !important; + stroke-width: 5; + stroke-opacity: 0.9; +} + +@keyframes marker-bounce { + 0% { transform: scale(0.3); opacity: 0; } + 50% { transform: scale(1.3); } + 70% { transform: scale(0.9); } + 100% { transform: scale(1.1); opacity: 1; } +} + +.marker-confirmed-bounce { + animation: marker-bounce 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +@keyframes route-flow { + from { stroke-dashoffset: 20; } + to { stroke-dashoffset: 0; } +} + +.route-line-flow { + animation: route-flow 1s linear infinite; +} + +@keyframes card-pop { + 0% { transform: scale(1); } + 50% { transform: scale(1.06); } + 100% { transform: scale(1.02); } +} + +.confirmed-card-sweep { + animation: card-emerald-sweep 1.5s cubic-bezier(0.25, 1, 0.5, 1) forwards, + card-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.char-span { + display: inline-block; +} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c74eaab..1bf4cd0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,8 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; export interface Hospital { hospital_id: string; name: string; + lat?: number; + lng?: number; eta_minutes: number; department_match: boolean; distance_km?: number; @@ -19,9 +21,13 @@ export interface EmergencyResponse { export interface EmergencyStatusResponse { request_id: string; + lat?: number; + lng?: number; hospitals: Array<{ hospital_id: string; name: string; + lat: number; + lng: number; eta_minutes: number; status: 'pending' | 'confirmed' | 'declined'; }>; diff --git a/frontend/src/lib/gsap-setup.ts b/frontend/src/lib/gsap-setup.ts index 6c0cc38..7ca11b2 100644 --- a/frontend/src/lib/gsap-setup.ts +++ b/frontend/src/lib/gsap-setup.ts @@ -1,8 +1,9 @@ import { gsap } from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; +import { MotionPathPlugin } from 'gsap/MotionPathPlugin'; // Register GSAP plugins once at app startup -gsap.registerPlugin(ScrollTrigger); +gsap.registerPlugin(ScrollTrigger, MotionPathPlugin); // Default GSAP configuration for premium feel gsap.defaults({ @@ -10,4 +11,4 @@ gsap.defaults({ duration: 1, }); -export { gsap, ScrollTrigger }; +export { gsap, ScrollTrigger, MotionPathPlugin }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c7f1782..5725518 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,14 +1,11 @@ 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( - - - + , )