.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