Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions cityassist/components/GoogleMapComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import { useLoadScript, GoogleMap, Marker, InfoWindow } from "@react-google-maps/api";
import type { Resource, Coordinate } from "../types";

interface GoogleMapComponentProps {
resources: Resource[];
center: Coordinate;
selectedId?: string;
onSelectResource: (id: string | undefined) => void;
query?: string;
onPlacesFound?: (resources: Resource[], summary: string) => void;
}

const libraries: ("places")[] = ["places"];

export default function GoogleMapComponent({
resources,
center,
selectedId,
onSelectResource,
query,
onPlacesFound,
}: GoogleMapComponentProps) {
const apiKey = (import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "").toString();
const { isLoaded, loadError } = useLoadScript({
googleMapsApiKey: apiKey,
libraries,
});

const [mapInstance, setMapInstance] = useState<google.maps.Map | null>(null);
const [infoOpenId, setInfoOpenId] = useState<string | null>(null);

useEffect(() => {
if (loadError) {
console.error("Google Maps failed to load:", loadError);
}
}, [loadError]);

// Run Places textSearch when query changes
/*
useEffect(() => {
if (!isLoaded || !query || !mapInstance) return;

const service = new google.maps.places.PlacesService(mapInstance);
const request: google.maps.places.TextSearchRequest = {
query,
location: new google.maps.LatLng(center.lat, center.lng),
radius: 5000,
};

service.textSearch(request, (results, status) => {
if (status === google.maps.places.PlacesServiceStatus.OK && results && results.length > 0) {
const mapped: Resource[] = results.map((r) => {
const type = r.types?.[0]?.replace(/_/g, " ") || "place";
const address = r.formatted_address || r.vicinity || "unknown location";
return {
id: r.place_id || r.id || Math.random().toString(36).slice(2, 9),
name: r.name || "",
description: `This is a ${type} located at ${address}.`,
category: (r.types && r.types[0]) || "place",
lat: r.geometry?.location?.lat() || 0,
lng: r.geometry?.location?.lng() || 0,
address: address,
hours: r.opening_hours?.weekday_text ? r.opening_hours.weekday_text.join("; ") : "Hours not available",
};
});

if (onPlacesFound) {
onPlacesFound(mapped, `Showing ${mapped.length} places from Google for “${query}”`);
}
} else {
// no results or error — let caller handle fallback
if (onPlacesFound) onPlacesFound([], `No Google Places results for “${query}”.`);
}
});
}, [isLoaded, query, center, onPlacesFound, mapInstance]);
*/

if (!isLoaded) return <div className="w-full h-full flex items-center justify-center">Loading map…</div>;

return (
<GoogleMap
onLoad={(map) => setMapInstance(map)}
mapContainerClassName="w-full h-full"
mapContainerStyle={{ width: "100%", height: "100%" }}
// Use defaultCenter so the map is not forcibly re-centered on every render —
// this allows the user to pan/zoom freely.
defaultCenter={{ lat: center.lat, lng: center.lng }}
zoom={13}
options={{ disableDefaultUI: true, gestureHandling: 'greedy' }}
>
{/* user marker */}
<Marker
position={{ lat: center.lat, lng: center.lng }}
icon={{
path: google.maps.SymbolPath.CIRCLE,
fillColor: "#2563eb",
fillOpacity: 1,
scale: 8,
strokeWeight: 2,
strokeColor: "#ffffff",
}}
/>

{resources.map((r) => (
<Marker
key={r.id}
position={{ lat: r.lat, lng: r.lng }}
onClick={() => {
onSelectResource(r.id);
setInfoOpenId(r.id);
}}
/>
))}

{infoOpenId && (() => {
const r = resources.find((x) => x.id === infoOpenId);
if (!r) return null;
return (
<InfoWindow position={{ lat: r.lat, lng: r.lng }} onCloseClick={() => { setInfoOpenId(null); onSelectResource(undefined); }}>
<div className="max-w-xs">
<h3 className="font-bold">{r.name}</h3>
<p className="text-sm">{r.address}</p>
<a className="text-blue-600 text-sm" href={`https://www.google.com/maps/dir/?api=1&destination=${r.lat},${r.lng}`} target="_blank" rel="noreferrer">Directions</a>
</div>
</InfoWindow>
);
})()}

</GoogleMap>
);
}
107 changes: 104 additions & 3 deletions cityassist/components/ResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { DarkModeContext } from "../App";

interface ResultsPageProps {
userLocation: Coordinate;
setUserLocation?: (c: Coordinate) => void;
}

const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation, setUserLocation }) => {
const { darkMode } = useContext(DarkModeContext);
// Manual query parsing from hash
const getQueryFromHash = () => {
Expand Down Expand Up @@ -51,6 +52,8 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
const [chatAnswer, setChatAnswer] = useState<ReturnType<
typeof getQuickAnswer
> | null>(null);
const [showLocationInput, setShowLocationInput] = useState(false);
const [manualAddress, setManualAddress] = useState("");

// Detect crisis keywords
const isCrisisQuery = (q: string) => {
Expand Down Expand Up @@ -78,6 +81,30 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
setChatAnswer(answer);
}, [queryParam]);

const handleSetManualLocation = (e: React.FormEvent) => {
e.preventDefault();
if (!manualAddress.trim()) return;

// Simple manual coordinate parsing if user enters "lat, lng"
// Since we removed Google Geocoding, we can't easily convert address to coords client-side without an API key.
// For now, we'll just alert the user or try to parse coordinates.
const parts = manualAddress.split(",");
if (parts.length === 2) {
const lat = parseFloat(parts[0].trim());
const lng = parseFloat(parts[1].trim());
if (!isNaN(lat) && !isNaN(lng)) {
if (setUserLocation) {
setUserLocation({ lat, lng });
}
setShowLocationInput(false);
setManualAddress("");
return;
}
}

alert("Please enter coordinates in 'lat, lng' format (e.g. 43.6532, -79.3832). Address lookup is currently disabled.");
};

const performSearch = useCallback(
async (searchQuery: string) => {
if (!searchQuery.trim()) return;
Expand All @@ -90,6 +117,53 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
const answer = getQuickAnswer(searchQuery);
setChatAnswer(answer);

// HARDCODED MODE: Filter local STATIC_RESOURCES instead of calling external APIs
// This ensures accurate, curated data is shown as requested.
const lowerQuery = searchQuery.toLowerCase();

// Simple keyword mapping for better natural language support
const keywords: Record<string, string[]> = {
shelter: ["sleep", "bed", "homeless", "night", "stay", "housing", "shelter"],
food: ["eat", "hungry", "meal", "groceries", "food", "bank"],
health: ["sick", "doctor", "medical", "hurt", "pain", "clinic", "hospital", "health"],
legal: ["lawyer", "court", "legal", "rights", "eviction"],
crisis: ["suicide", "help", "emergency", "danger", "safe", "crisis"],
community: ["library", "internet", "wifi", "community", "support"]
};

// Determine target categories based on query
const targetCategories = Object.entries(keywords)
.filter(([_, words]) => words.some(w => lowerQuery.includes(w)))
.map(([cat]) => cat);

const filtered = STATIC_RESOURCES.filter(r => {
// 1. Direct match
if (r.name.toLowerCase().includes(lowerQuery) ||
r.category.toLowerCase().includes(lowerQuery) ||
r.description.toLowerCase().includes(lowerQuery) ||
r.address.toLowerCase().includes(lowerQuery)) {
return true;
}
// 2. Category match via keywords
if (targetCategories.includes(r.category)) {
return true;
}
return false;
});

setResources(filtered);
setSummary(filtered.length > 0
? `Found ${filtered.length} resources matching "${searchQuery}" from our verified directory.`
: `No verified resources found for "${searchQuery}". Try searching for "food", "shelter", or "health".`
);

if (filtered.length > 0) {
setSelectedId(filtered[0].id);
}
setLoading(false);

/*
// External API calls disabled for hardcoded mode
try {
const result: AIResponse = await searchResourcesWithGemini(
searchQuery,
Expand All @@ -107,6 +181,7 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
} finally {
setLoading(false);
}
*/
},
[userLocation]
);
Expand All @@ -130,7 +205,8 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
};

const handleChipClick = (category: string) => {
navigate(`/map?q=${encodeURIComponent("Find " + category)}`);
// Directly search for the category name to match our hardcoded data
navigate(`/map?q=${encodeURIComponent(category)}`);
};

const handleBack = () => {
Expand Down Expand Up @@ -254,6 +330,31 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
</button>
</form>

{/* Manual location setter - in case geolocation is denied */}
<div className="mt-3 flex items-center gap-3">
<button
onClick={() => setShowLocationInput((s) => !s)}
className="px-3 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-sm text-gray-700"
>
{showLocationInput ? "Hide location" : "Set my location"}
</button>

{showLocationInput && (
<form onSubmit={handleSetManualLocation} className="flex items-center gap-2 flex-1">
<input
type="text"
value={manualAddress}
onChange={(e) => setManualAddress(e.target.value)}
placeholder="Enter lat, lng"
className="flex-1 px-2 py-1 rounded-md border text-sm"
/>
<button className="px-3 py-1 rounded-md bg-indigo-500 text-white text-sm whitespace-nowrap">
Update
</button>
</form>
)}
</div>

{/* Quick Filters */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 no-scrollbar">
{["Food", "Shelter", "Health", "Legal"].map((cat) => (
Expand Down Expand Up @@ -325,7 +426,7 @@ const ResultsPage: React.FC<ResultsPageProps> = ({ userLocation }) => {
darkMode ? "text-gray-200" : "text-blue-900"
}`}
>
CityAssist AI
6ixAssist AI
</h4>
<p
className={`text-sm leading-relaxed transition-colors duration-300 ${
Expand Down
Loading