diff --git a/ai/.env.example b/ai/.env.example new file mode 100644 index 0000000..0a7acb6 --- /dev/null +++ b/ai/.env.example @@ -0,0 +1,4 @@ +PORT=5000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://pulse-11de7.web.app +GROQ_API_KEY=your_groq_api_key +GEMINI_API_KEY=your_gemini_api_key diff --git a/ai/Dockerfile b/ai/Dockerfile new file mode 100644 index 0000000..face2a8 --- /dev/null +++ b/ai/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT:-5000} app:app"] diff --git a/ai/app.py b/ai/app.py index 215b7a8..d5dd284 100644 --- a/ai/app.py +++ b/ai/app.py @@ -11,7 +11,20 @@ import os import requests app = Flask(__name__) -CORS(app) # Allows Node.js backend to call this +default_allowed_origins = [ + "https://pulse-11de7.web.app", + "https://pulse-11de7.firebaseapp.com", + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", +] +allowed_origins = default_allowed_origins + [ + origin.strip() + for origin in os.getenv("CORS_ORIGINS", "").split(",") + if origin.strip() +] +CORS(app, resources={r"/*": {"origins": allowed_origins}}) # ── Health check ────────────────────────────── @app.route('/health', methods=['GET']) @@ -393,5 +406,5 @@ def pre_alert(): print(" POST /cluster") print(" POST /match") print(" POST /escalate") - app.run(host='0.0.0.0', port=port, debug=True) + app.run(host='0.0.0.0', port=port, debug=os.getenv("FLASK_DEBUG") == "1") diff --git a/ai/requirements.txt b/ai/requirements.txt index 287f83d..cb7fed1 100644 --- a/ai/requirements.txt +++ b/ai/requirements.txt @@ -1,6 +1,7 @@ flask flask-cors +gunicorn groq google-genai firebase-admin -python-dotenv \ No newline at end of file +python-dotenv diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5d3b88e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +PORT=3000 +AI_BASE_URL=http://localhost:5000 +FRONTEND_PUBLIC_URL=http://localhost:5173 +BACKEND_PUBLIC_URL=http://localhost:3000 +TWILIO_WEBHOOK_BASE_URL=http://localhost:3000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://pulse-11de7.web.app + +# Use this on Render. For local dev you can also place serviceAccountKey.json in backend/. +FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"pulse-11de7"} + +TWILIO_ACCOUNT_SID=your_twilio_account_sid +TWILIO_AUTH_TOKEN=your_twilio_auth_token +TWILIO_PHONE_NUMBER=+14155238886 +TWILIO_REAL_NUMBER=+15551234567 +MY_PHONE_NUMBER=+919999999999 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4c82e25 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +ENV NODE_ENV=production +EXPOSE 3000 + +CMD ["node", "index.js"] diff --git a/backend/index.js b/backend/index.js index bc4a7de..53e46f8 100644 --- a/backend/index.js +++ b/backend/index.js @@ -4,7 +4,8 @@ const express = require('express'); const cors = require('cors'); const admin = require('firebase-admin'); const twilio = require('twilio'); // MUST COME BEFORE CLIENT -const serviceAccount = require('./serviceAccountKey.json'); +const fs = require('fs'); +const path = require('path'); const app = express(); app.get('/test', (req, res) => { @@ -12,19 +13,62 @@ app.get('/test', (req, res) => { }); app.use(express.urlencoded({ extended: true })); app.use(express.json()); -app.use(cors()); + +const defaultAllowedOrigins = [ + 'https://pulse-11de7.web.app', + 'https://pulse-11de7.firebaseapp.com', + 'http://localhost:5173', + 'http://localhost:3000', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:3000' +]; + +const allowedOrigins = [ + ...defaultAllowedOrigins, + ...(process.env.CORS_ORIGINS || '').split(',').map(origin => origin.trim()).filter(Boolean) +]; + +app.use(cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error(`CORS blocked origin: ${origin}`)); + }, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); const client = twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN ); +function loadFirebaseCredential() { + if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) { + return JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON); + } + + const localServiceAccountPath = path.join(__dirname, 'serviceAccountKey.json'); + if (fs.existsSync(localServiceAccountPath)) { + return require(localServiceAccountPath); + } + + throw new Error('Missing Firebase Admin credentials. Set FIREBASE_SERVICE_ACCOUNT_JSON or add backend/serviceAccountKey.json locally.'); +} + admin.initializeApp({ - credential: admin.credential.cert(serviceAccount) + credential: admin.credential.cert(loadFirebaseCredential()) }); const db = admin.firestore(); const PORT = process.env.PORT || 3000; +const AI_BASE_URL = (process.env.AI_BASE_URL || 'http://localhost:5000').replace(/\/$/, ''); +const FRONTEND_PUBLIC_URL = (process.env.FRONTEND_PUBLIC_URL || process.env.PUBLIC_BASE_URL || 'http://localhost:5173').replace(/\/$/, ''); +const BACKEND_PUBLIC_URL = (process.env.BACKEND_PUBLIC_URL || process.env.RENDER_EXTERNAL_URL || `http://localhost:${PORT}`).replace(/\/$/, ''); +const TWILIO_WEBHOOK_BASE_URL = (process.env.TWILIO_WEBHOOK_BASE_URL || process.env.NGROK_URL || BACKEND_PUBLIC_URL).replace(/\/$/, ''); + +function aiEndpoint(route) { + return `${AI_BASE_URL}${route}`; +} // ─── HELPER FUNCTIONS ─────────────────────────────────────────────── // Distance between two coordinates in km @@ -54,7 +98,7 @@ function safe(val, fallback = '') { async function enrichWithPULSEAI(reportId, rawText) { try { - const res = await fetch('http://localhost:5000/analyze', { + const res = await fetch(aiEndpoint('/analyze'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: rawText }) @@ -103,7 +147,7 @@ async function enrichWithPULSEAI(reportId, rawText) { affected_people: doc.data().affected_people || 0 })); - const clusterRes = await fetch('http://localhost:5000/cluster', { + const clusterRes = await fetch(aiEndpoint('/cluster'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reports }) @@ -129,7 +173,7 @@ for (const cluster of clusterData.clusters) { } // Escalate urgency on old unresolved reports -fetch('http://localhost:5000/escalate', { +fetch(aiEndpoint('/escalate'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reports: reports }) @@ -194,7 +238,7 @@ async function deleteConversation(senderNumber) { // Detect language using AI async function detectLanguage(text) { try { - const res = await fetch('http://localhost:5000/analyze', { + const res = await fetch(aiEndpoint('/analyze'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) @@ -388,7 +432,7 @@ async function processVerificationAsync(mediaUrl, senderNumber, task, taskId, vo `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}` ).toString('base64'); - const verifyRes = await fetch('http://localhost:5000/verify-proof', { + const verifyRes = await fetch(aiEndpoint('/verify-proof'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1074,14 +1118,19 @@ app.post('/sms-reply', async (req, res) => { // START CALL app.post("/start-call", async (req, res) => { try { + const toPhone = req.body.phone || process.env.MY_PHONE_NUMBER; + if (!toPhone) { + return res.status(400).json({ error: 'Missing destination phone number' }); + } + await client.calls.create({ - to: process.env.MY_PHONE_NUMBER, // 👈 YOUR PHONE HERE + to: toPhone, from: process.env.TWILIO_REAL_NUMBER, // 👈 TWILIO NUMBER - url: `${process.env.NGROK_URL}/incoming-call` + url: `${TWILIO_WEBHOOK_BASE_URL}/incoming-call` }); // twiml: ` // -// ${process.env.NGROK_URL}/incoming-call +// ${TWILIO_WEBHOOK_BASE_URL}/incoming-call // // ` @@ -1100,7 +1149,7 @@ app.all('/incoming-call', (req, res) => { res.set('Content-Type', 'text/xml'); res.send(` - + Namaste. PULSE mein aapka swagat hai. Hindi ke liye 1 dabaiye. @@ -1109,7 +1158,7 @@ app.all('/incoming-call', (req, res) => { English ke liye 4 dabaiye. - ${process.env.NGROK_URL}/incoming-call + ${TWILIO_WEBHOOK_BASE_URL}/incoming-call `); }); @@ -1120,7 +1169,7 @@ app.all('/handle-language', (req, res) => { if (!digit) { return res.send(` - ${process.env.NGROK_URL}/incoming-call + ${TWILIO_WEBHOOK_BASE_URL}/incoming-call `); } @@ -1142,13 +1191,13 @@ app.all('/handle-language', (req, res) => { res.set('Content-Type', 'text/xml'); res.send(` - + ${menus[lang.code]} - ${process.env.NGROK_URL}/handle-language + ${TWILIO_WEBHOOK_BASE_URL}/handle-language `); }); @@ -1175,7 +1224,7 @@ app.all('/handle-keypress', (req, res) => { ${confirms[lang]} { } // Call Person A's report generator - const flaskRes = await fetch('http://localhost:5000/generate-report', { + const flaskRes = await fetch(aiEndpoint('/generate-report'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cluster, reports: reportsData }) @@ -1913,8 +1962,9 @@ app.post('/chat', async (req, res) => { const text = message.toLowerCase(); // 🔥 Get number from ENV (NO HARDCODE) - const phone = process.env.TWILIO_PHONE_NUMBER; - const baseUrl = process.env.PUBLIC_BASE_URL || 'http://localhost:3000'; + const phone = process.env.TWILIO_PHONE_NUMBER || '+14155238886'; + const frontendUrl = FRONTEND_PUBLIC_URL; + const backendUrl = BACKEND_PUBLIC_URL; // Convert to WhatsApp link format (remove +) const whatsappLink = `https://wa.me/${phone.replace('+', '')}`; @@ -1948,7 +1998,7 @@ Please report it properly so we can act fast: actions: [ { label: "Open Intake Form", - link: `${baseUrl}/intake?msg=${encodeURIComponent(message)}` + link: `${frontendUrl}/intake?msg=${encodeURIComponent(message)}` }, { label: "WhatsApp Report", link: whatsappLink } ] @@ -1958,7 +2008,7 @@ Please report it properly so we can act fast: // ─── 3. ANALYTICS MODE 📊 ───────────────────────── if (isAnalytics) { - const analyticsRes = await fetch(`${baseUrl}/analytics`); + const analyticsRes = await fetch(`${backendUrl}/analytics`); const data = await analyticsRes.json(); const affected = data.analytics?.reports?.total_affected || 0; @@ -1977,7 +2027,7 @@ Volunteers Active: ${volunteers}` // ─── 4. ALERT MODE ⚠️ ───────────────────────────── if (isAlerts) { - const alertRes = await fetch(`${baseUrl}/predictive-alerts`); + const alertRes = await fetch(`${backendUrl}/predictive-alerts`); const data = await alertRes.json(); if (!data.alerts || data.alerts.length === 0) { @@ -2012,7 +2062,7 @@ Basically… we turn chaos into coordinated action.` // ─── 6. AI MODE 🤖 ───────────────────────────── -const aiRes = await fetch(`${process.env.AI_BASE_URL}/ask-ai`, { +const aiRes = await fetch(aiEndpoint('/ask-ai'), { method: "POST", headers: { "Content-Type": "application/json" diff --git a/backend/package.json b/backend/package.json index 92a5d8b..c57f0d4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md new file mode 100644 index 0000000..06cfe01 --- /dev/null +++ b/docs/deployment-checklist.md @@ -0,0 +1,85 @@ +# PULSE Deployment Checklist + +## 1. Render + +- Create a Render Blueprint from this fork. +- Confirm two services are created: + - `pulse-backend` + - `pulse-ai` +- Set all backend secret environment variables. +- Set all AI secret environment variables. +- Copy the final Render URLs. +- Set backend `AI_BASE_URL` to the AI service URL. +- Set backend `BACKEND_PUBLIC_URL` and `TWILIO_WEBHOOK_BASE_URL` to the backend service URL. +- Redeploy backend after updating URL env vars. +- Verify: + - `GET /` on backend. + - `GET /health` on AI. + - `POST /demo-trigger` on backend. + - `POST /analyze` on AI. + +## 2. Firebase Hosting + +- Create or update `frontend/.env` using `frontend/.env.example`. +- Set: + - `VITE_BACKEND_URL` + - `VITE_AI_URL` + - `VITE_GOOGLE_MAPS_API_KEY` + - `VITE_GOOGLE_MAP_ID` +- Build the frontend: + +```bash +cd frontend +npm ci +npm run build +``` + +- Deploy: + +```bash +firebase deploy --only hosting +``` + +## 3. Google Maps + +- Enable Maps JavaScript API. +- Create a Map ID for Advanced Markers. +- Restrict the API key to Firebase Hosting and localhost referrers. +- Confirm the dashboard loads without `NoApiKeys` or `ApiProjectMapError`. +- Confirm crisis clusters and individual reports appear as map markers. + +## 4. Firebase + +- Confirm Firestore collections are still available: + - `reports` + - `clusters` + - `volunteers` + - `tasks` + - `ngos` +- Confirm Firebase Auth still accepts the intended Google sign-in domains. +- Confirm backend `FIREBASE_SERVICE_ACCOUNT_JSON` is valid. + +## 5. Twilio + +- Update Twilio WhatsApp webhook to `/incoming-message`. +- Update SMS webhook to `/sms-reply`. +- Update voice webhook to `/incoming-call`. +- Confirm `TWILIO_PHONE_NUMBER` is the raw sender number, for example `+14155238886`. +- Confirm `TWILIO_REAL_NUMBER` is the voice/SMS-capable Twilio number. +- Test: + - WhatsApp report intake. + - Volunteer `ACCEPT`, `DONE`, and proof photo flow. + - SMS reply flow. + - IVR demo call. + +## 6. App Sanity Checks + +- Dashboard loads Firestore reports and clusters. +- Fire Demo creates reports and clusters. +- Generate Report returns AI output. +- Chatbot returns quick intent responses and AI fallback. +- Intake form analyzes and saves reports. +- Volunteer registration posts to backend. +- Volunteer portal can accept and complete tasks. +- Analytics page reads backend analytics. +- Predictive alerts page reads backend predictions. diff --git a/docs/render-migration.md b/docs/render-migration.md new file mode 100644 index 0000000..16ad05b --- /dev/null +++ b/docs/render-migration.md @@ -0,0 +1,142 @@ +# PULSE Render Migration + +This migration replaces the expired Railway backend with Render free web services while keeping the React/Vite frontend on Firebase Hosting. + +## What Changed + +- Removed all live frontend references to the expired Railway backend URL. +- Added shared frontend API helpers in `frontend/src/config/api.js`. +- Moved frontend backend and AI URLs to `VITE_BACKEND_URL` and `VITE_AI_URL`. +- Added Render Docker deployments for: + - `backend/` Node.js Express API. + - `ai/` Python Flask AI service. +- Added `render.yaml` for a two-service Render Blueprint. +- Configured Express CORS for: + - `https://pulse-11de7.web.app` + - `https://pulse-11de7.firebaseapp.com` + - localhost and 127.0.0.1 development origins + - optional extra origins through `CORS_ORIGINS` +- Configured Flask CORS with the same origin strategy. +- Replaced backend calls to `http://localhost:5000` with `AI_BASE_URL`. +- Added `FIREBASE_SERVICE_ACCOUNT_JSON` support for Firebase Admin on hosted environments. +- Updated Twilio IVR webhook URL generation to use `TWILIO_WEBHOOK_BASE_URL`. +- Replaced deprecated Google Maps `Marker` usage on the dashboard with `AdvancedMarkerElement`. + +## Hosting Choice + +Render is the recommended free migration target for this recovery because its official docs still support free web services for Node.js and Python. Fly.io is trial/credit oriented for new accounts, and Koyeb's free tier is less suitable for this two-service setup. + +Render free services may spin down after inactivity. First request after sleep can be slow, so demo flows should be warmed up before presentations. + +## Deployment URLs + +Fill these in after Render creates the services: + +- Backend API: `https://.onrender.com` +- AI service: `https://.onrender.com` +- Frontend: `https://pulse-11de7.web.app` + +Do not guess these values. Copy them from the Render dashboard after deployment. + +## Required Environment Variables + +### Firebase Hosting frontend + +Set these before building/deploying the frontend: + +```bash +VITE_BACKEND_URL=https://.onrender.com +VITE_AI_URL=https://.onrender.com +VITE_GOOGLE_MAPS_API_KEY= +VITE_GOOGLE_MAP_ID= +VITE_WHATSAPP_NUMBER= +``` + +Firebase config variables are optional because the current `pulse-11de7` values remain fallback defaults: + +```bash +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN=pulse-11de7.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=pulse-11de7 +VITE_FIREBASE_STORAGE_BUCKET=pulse-11de7.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=383499078117 +VITE_FIREBASE_APP_ID=1:383499078117:web:dc5a48db35b128abb3470c +``` + +### Render backend service + +```bash +AI_BASE_URL=https://.onrender.com +FRONTEND_PUBLIC_URL=https://pulse-11de7.web.app +BACKEND_PUBLIC_URL=https://.onrender.com +TWILIO_WEBHOOK_BASE_URL=https://.onrender.com +CORS_ORIGINS=https://pulse-11de7.web.app,https://pulse-11de7.firebaseapp.com +FIREBASE_SERVICE_ACCOUNT_JSON= +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER= +TWILIO_REAL_NUMBER= +MY_PHONE_NUMBER= +``` + +### Render AI service + +```bash +CORS_ORIGINS=https://pulse-11de7.web.app,https://pulse-11de7.firebaseapp.com +GROQ_API_KEY= +GEMINI_API_KEY= +``` + +## Google Maps Console Actions + +The dashboard requires a valid Maps JavaScript API browser key. + +1. Open Google Cloud Console for the project that owns the key. +2. Enable **Maps JavaScript API**. +3. Create or confirm a **Map ID** for Advanced Markers. +4. Add `VITE_GOOGLE_MAP_ID` to the frontend environment. +5. Restrict the browser key by HTTP referrer: + - `https://pulse-11de7.web.app/*` + - `https://pulse-11de7.firebaseapp.com/*` + - `http://localhost:5173/*` + - `http://127.0.0.1:5173/*` +6. Confirm billing is enabled if Google requires it for the project. + +`ApiProjectMapError` normally means the key belongs to the wrong Google Cloud project, the API is disabled, billing is unavailable, or referrer restrictions do not match the deployed domain. + +`NoApiKeys` means `VITE_GOOGLE_MAPS_API_KEY` was not present at build time. + +## Firebase Notes + +- Firestore and Firebase Auth remain on project `pulse-11de7`. +- Frontend Firebase config can be overridden with `VITE_FIREBASE_*` variables, but the existing values remain as fallbacks. +- Backend Firebase Admin now reads `FIREBASE_SERVICE_ACCOUNT_JSON` on Render. +- Never commit `serviceAccountKey.json`; keep it only for local development. + +## Twilio Webhooks + +After Render backend deployment, update Twilio webhook URLs: + +- WhatsApp incoming message webhook: `https://.onrender.com/incoming-message` +- SMS reply webhook: `https://.onrender.com/sms-reply` +- Voice call webhook: `https://.onrender.com/incoming-call` + +Set `TWILIO_WEBHOOK_BASE_URL` to the backend Render URL so generated IVR redirects use the hosted backend instead of ngrok. + +## AI Endpoint Verification + +After deploying both services, verify: + +```bash +curl https://.onrender.com/health +curl -X POST https://.onrender.com/analyze \ + -H "Content-Type: application/json" \ + -d "{\"text\":\"Need water in Hyderabad for 50 people\"}" +curl -X POST https://.onrender.com/demo-trigger +``` + +The backend proxies or uses AI for `/analyze`, `/cluster`, `/escalate`, `/generate-report`, `/verify-proof`, and `/ask-ai`. + +## COOP Warning + +The Cross-Origin-Opener-Policy `window.close` warning is commonly emitted by Google/Firebase popup flows in modern browsers. It is a browser security warning, not a backend outage. If popup auth stops working, switch the auth flow to Firebase redirect sign-in; no migration-specific backend change is required. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..7e6ed4d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +VITE_BACKEND_URL=http://localhost:3000 +VITE_AI_URL=http://localhost:5000 +VITE_GOOGLE_MAPS_API_KEY=your_google_maps_browser_key +VITE_GOOGLE_MAP_ID=your_google_maps_map_id +VITE_WHATSAPP_NUMBER=+14155238886 + +# Optional. Existing pulse-11de7 values are used as code fallbacks. +VITE_FIREBASE_API_KEY=your_firebase_web_api_key +VITE_FIREBASE_AUTH_DOMAIN=pulse-11de7.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=pulse-11de7 +VITE_FIREBASE_STORAGE_BUCKET=pulse-11de7.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=383499078117 +VITE_FIREBASE_APP_ID=1:383499078117:web:dc5a48db35b128abb3470c diff --git a/frontend/src/components/Chatbot.jsx b/frontend/src/components/Chatbot.jsx index 86b46d7..895a590 100644 --- a/frontend/src/components/Chatbot.jsx +++ b/frontend/src/components/Chatbot.jsx @@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom" import { useState, useRef, useEffect } from "react" import { motion, AnimatePresence } from "framer-motion" import { MessageCircle } from "lucide-react" +import { apiUrl } from "../config/api" function Chatbot() { const [open, setOpen] = useState(false) @@ -31,7 +32,7 @@ function Chatbot() { setMessages(prev => [...prev, loadingMsg]) try { - const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/chat`, { + const res = await fetch(apiUrl('/chat'), { method: "POST", headers: { "Content-Type": "application/json" diff --git a/frontend/src/config/api.js b/frontend/src/config/api.js new file mode 100644 index 0000000..812e97c --- /dev/null +++ b/frontend/src/config/api.js @@ -0,0 +1,12 @@ +const trimTrailingSlash = (value) => value?.replace(/\/$/, '') + +export const API_BASE = trimTrailingSlash( + import.meta.env.VITE_BACKEND_URL || 'http://localhost:3000' +) + +export const AI_BASE = trimTrailingSlash( + import.meta.env.VITE_AI_URL || 'http://localhost:5000' +) + +export const apiUrl = (path) => `${API_BASE}${path}` +export const aiUrl = (path) => `${AI_BASE}${path}` diff --git a/frontend/src/firebase.js b/frontend/src/firebase.js index e62105e..4913280 100644 --- a/frontend/src/firebase.js +++ b/frontend/src/firebase.js @@ -3,12 +3,12 @@ import { getFirestore } from "firebase/firestore"; import { getAuth, GoogleAuthProvider } from "firebase/auth"; const firebaseConfig = { - apiKey: "AIzaSyBgXF4KinKVngapDbyFIVOeSt7Z9Tdwx9Q", - authDomain: "pulse-11de7.firebaseapp.com", - projectId: "pulse-11de7", - storageBucket: "pulse-11de7.firebasestorage.app", - messagingSenderId: "383499078117", - appId: "1:383499078117:web:dc5a48db35b128abb3470c" + apiKey: import.meta.env.VITE_FIREBASE_API_KEY || "AIzaSyBgXF4KinKVngapDbyFIVOeSt7Z9Tdwx9Q", + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || "pulse-11de7.firebaseapp.com", + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || "pulse-11de7", + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || "pulse-11de7.firebasestorage.app", + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || "383499078117", + appId: import.meta.env.VITE_FIREBASE_APP_ID || "1:383499078117:web:dc5a48db35b128abb3470c" }; const app = initializeApp(firebaseConfig); diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx index 1695022..8289767 100644 --- a/frontend/src/pages/Analytics.jsx +++ b/frontend/src/pages/Analytics.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { apiUrl } from '../config/api' function Analytics() { const { t } = useTranslation() @@ -8,7 +9,7 @@ function Analytics() { const [loading, setLoading] = useState(true) useEffect(() => { - fetch('https://pulse-backend-production-cd6d.up.railway.app/analytics') + fetch(apiUrl('/analytics')) .then(r => r.json()) .then(data => { setAnalytics(data.analytics); setLoading(false) }) .catch(() => setLoading(false)) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 3e4dd19..5583797 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react' -import { GoogleMap, useJsApiLoader, Marker, InfoWindow } from '@react-google-maps/api' +import { GoogleMap, useJsApiLoader, InfoWindow, useGoogleMap } from '@react-google-maps/api' import { collection, onSnapshot, getDocs, query, where, orderBy, doc } from 'firebase/firestore' import { db } from '../firebase' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { API_BASE } from '../config/api' const mapContainerStyle = { width: '100%', height: '100%' } const center = { lat: 20.5937, lng: 78.9629 } -const API_BASE = import.meta.env.VITE_BACKEND_URL +const googleMapsLibraries = ['marker'] // ─── Urgency helpers ────────────────────────────────────────────────────────── const getColor = (urgency) => { @@ -22,29 +23,47 @@ const getLabel = (urgency) => { return { textKey: 'medium', bg: 'bg-yellow-500' } } -// ─── Icon factories ─────────────────────────────────────────────────────────── -const clusterIcon = (urgency, reportCount) => { - if (!window.google) return null - return { - path: window.google.maps.SymbolPath.CIRCLE, - scale: Math.min(14 + (reportCount || 1) * 1.5, 24), - fillColor: getColor(urgency), - fillOpacity: 1, - strokeWeight: 2.5, - strokeColor: '#ffffff', - } -} +function PulseAdvancedMarker({ position, urgency, count, zIndex, onClick, title }) { + const map = useGoogleMap() -const unclusteredIcon = () => { - if (!window.google) return null - return { - path: window.google.maps.SymbolPath.CIRCLE, - scale: 7, - fillColor: '#3b82f6', - fillOpacity: 0.9, - strokeWeight: 2, - strokeColor: '#ffffff', - } + useEffect(() => { + if (!map || !window.google?.maps?.marker?.AdvancedMarkerElement) return + + const content = document.createElement('button') + const size = count ? Math.min(28 + count * 3, 48) : 18 + content.type = 'button' + content.ariaLabel = title || 'Map marker' + content.textContent = count ? String(count) : '' + content.style.width = `${size}px` + content.style.height = `${size}px` + content.style.borderRadius = '9999px' + content.style.border = '2.5px solid #ffffff' + content.style.background = count ? getColor(urgency) : '#3b82f6' + content.style.color = '#ffffff' + content.style.fontSize = '11px' + content.style.fontWeight = '700' + content.style.display = 'grid' + content.style.placeItems = 'center' + content.style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.35)' + content.style.cursor = 'pointer' + + const marker = new window.google.maps.marker.AdvancedMarkerElement({ + map, + position, + content, + zIndex, + title + }) + + const listener = marker.addListener('click', onClick) + + return () => { + listener.remove() + marker.map = null + } + }, [map, position.lat, position.lng, urgency, count, zIndex, onClick, title]) + + return null } // ─── Action functions ───────────────────────────────────────────────────────── @@ -157,6 +176,8 @@ function Dashboard() { const { isLoaded } = useJsApiLoader({ googleMapsApiKey: import.meta.env.VITE_GOOGLE_MAPS_API_KEY, + mapIds: import.meta.env.VITE_GOOGLE_MAP_ID ? [import.meta.env.VITE_GOOGLE_MAP_ID] : undefined, + libraries: googleMapsLibraries, }) // ── Firestore listeners ──────────────────────────────────────────────────── @@ -313,7 +334,12 @@ function Dashboard() { {/* ── MAP ── */}
- + {/* 🔵 Unclustered report dots */} {unclusteredReports.map(report => { @@ -321,11 +347,11 @@ function Dashboard() { const lng = report.location_lng ?? report.lng ?? report.longitude if (!lat || !lng) return null return ( - setHoveredReport(hoveredReport?.id === report.id ? null : report)} /> ) @@ -369,17 +395,13 @@ function Dashboard() { {/* 🔴/🟠/🟡 Cluster markers */} {clusters.map(cluster => ( - { setHoveredReport(null) setSelected(cluster) diff --git a/frontend/src/pages/Intake.jsx b/frontend/src/pages/Intake.jsx index 022d7ab..d47b348 100644 --- a/frontend/src/pages/Intake.jsx +++ b/frontend/src/pages/Intake.jsx @@ -4,6 +4,7 @@ import { collection, addDoc, serverTimestamp } from 'firebase/firestore' import { db } from '../firebase' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { aiUrl } from '../config/api' function Intake() { const { t } = useTranslation() @@ -33,9 +34,7 @@ function Intake() { try { const fullText = `${form.need_type ? form.need_type + ': ' : ''}${form.message}. Location: ${form.location}` -const AI_BASE = import.meta.env.VITE_AI_URL - -const aiRes = await fetch(`${AI_BASE}/analyze`, { +const aiRes = await fetch(aiUrl('/analyze'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: fullText }) diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index e8b1c8d..2843147 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { motion } from 'framer-motion' import { useTranslation } from 'react-i18next' import LanguageSwitcher from '../components/LanguageSwitcher' +import { apiUrl } from '../config/api' function Landing() { @@ -82,7 +83,7 @@ const startCall = async () => { setCallText('📞 Requesting call...') try { - const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/start-call`, { + const res = await fetch(apiUrl('/start-call'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: ivrPhone.trim() }) @@ -403,4 +404,4 @@ if (res.ok) { ) } -export default Landing \ No newline at end of file +export default Landing diff --git a/frontend/src/pages/PredictiveAlerts.jsx b/frontend/src/pages/PredictiveAlerts.jsx index b0ddd2b..7dc3395 100644 --- a/frontend/src/pages/PredictiveAlerts.jsx +++ b/frontend/src/pages/PredictiveAlerts.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { apiUrl } from '../config/api' function PredictiveAlerts() { const { t } = useTranslation() @@ -8,7 +9,7 @@ function PredictiveAlerts() { const [loading, setLoading] = useState(true) useEffect(() => { - fetch('https://pulse-backend-production-cd6d.up.railway.app/predictive-alerts') + fetch(apiUrl('/predictive-alerts')) .then(r => r.json()) .then(data => { setAlerts(data.alerts || []); setLoading(false) }) .catch(() => setLoading(false)) diff --git a/frontend/src/pages/Volunteer.jsx b/frontend/src/pages/Volunteer.jsx index 46eae46..33506ab 100644 --- a/frontend/src/pages/Volunteer.jsx +++ b/frontend/src/pages/Volunteer.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { apiUrl } from '../config/api' function Volunteer() { const { t } = useTranslation() @@ -45,7 +46,7 @@ function Volunteer() { setLoading(true) try { - const res = await fetch('https://pulse-backend-production-cd6d.up.railway.app/register-volunteer', { + const res = await fetch(apiUrl('/register-volunteer'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) diff --git a/frontend/src/pages/VolunteerPortal.jsx b/frontend/src/pages/VolunteerPortal.jsx index b322aa2..e4e3ef6 100644 --- a/frontend/src/pages/VolunteerPortal.jsx +++ b/frontend/src/pages/VolunteerPortal.jsx @@ -3,6 +3,7 @@ import { collection, onSnapshot, query, where } from 'firebase/firestore' import { db } from '../firebase' import Navbar from '../components/Navbar' import { useTranslation } from 'react-i18next' +import { apiUrl } from '../config/api' function VolunteerPortal() { const { t } = useTranslation() @@ -25,7 +26,7 @@ function VolunteerPortal() { const updateTask = async (taskId, status) => { setActionLoading(taskId + status) try { - await fetch('https://pulse-backend-production-cd6d.up.railway.app/update-task', { + await fetch(apiUrl('/update-task'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId, status }) }) diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..367e4a5 --- /dev/null +++ b/render.yaml @@ -0,0 +1,46 @@ +services: + - type: web + name: pulse-backend + runtime: docker + plan: free + rootDir: backend + healthCheckPath: / + envVars: + - key: NODE_ENV + value: production + - key: FRONTEND_PUBLIC_URL + value: https://pulse-11de7.web.app + - key: CORS_ORIGINS + value: https://pulse-11de7.web.app,https://pulse-11de7.firebaseapp.com + - key: AI_BASE_URL + sync: false + - key: BACKEND_PUBLIC_URL + sync: false + - key: TWILIO_WEBHOOK_BASE_URL + sync: false + - key: FIREBASE_SERVICE_ACCOUNT_JSON + sync: false + - key: TWILIO_ACCOUNT_SID + sync: false + - key: TWILIO_AUTH_TOKEN + sync: false + - key: TWILIO_PHONE_NUMBER + sync: false + - key: TWILIO_REAL_NUMBER + sync: false + - key: MY_PHONE_NUMBER + sync: false + + - type: web + name: pulse-ai + runtime: docker + plan: free + rootDir: ai + healthCheckPath: /health + envVars: + - key: CORS_ORIGINS + value: https://pulse-11de7.web.app,https://pulse-11de7.firebaseapp.com + - key: GROQ_API_KEY + sync: false + - key: GEMINI_API_KEY + sync: false