diff --git a/.gitignore b/.gitignore index ec6b88b..db6e4e8 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,5 @@ frontend/.vite-temp/ # Project specific notebooks/gmaps/ data/processed/ +.bak +/netlify \ No newline at end of file diff --git a/backend/routes/predict.js b/backend/routes/predict.js index 4d9a5ba..23f8e4c 100644 --- a/backend/routes/predict.js +++ b/backend/routes/predict.js @@ -59,11 +59,15 @@ function estimateTravelTime(distanceKm, startTime, city) { return minutes > 5 ? minutes : 5; } +// This file now exclusively serves as an Express route handler for local development or a traditional server. export const predictRoute = (req, res) => { + const requestId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; + console.log(`[predict.js] [${requestId}] Received prediction request.`); try { const { from, to, startTime, city } = req.body || {}; if (!from || !to || !startTime || !city) { + console.warn(`[predict.js] [${requestId}] Bad Request: Missing required fields. Body:`, req.body); return res.status(400).json({ error: 'Missing required fields: from, to, startTime, city' }); } @@ -73,6 +77,7 @@ export const predictRoute = (req, res) => { from.lat < -90 || from.lat > 90 || to.lat < -90 || to.lat > 90 || from.lon < -180 || from.lon > 180 || to.lon < -180 || to.lon > 180 ) { + console.warn(`[predict.js] [${requestId}] Bad Request: Invalid coordinates. From:`, from, "To:", to); return res.status(400).json({ error: 'Invalid coordinates' }); } @@ -81,9 +86,11 @@ export const predictRoute = (req, res) => { const cacheKey = createCacheKey(from, to, startTime, cityKey); const cached = predictionCache.get(cacheKey); if (cached !== undefined) { + console.log(`[predict.js] [${requestId}] Cache HIT for key: ${cacheKey}`); return res.json({ ...cached, cached: true }); } + console.log(`[predict.js] [${requestId}] Cache MISS for key: ${cacheKey}. Calculating new prediction.`); const distanceKm = calculateDistance(from.lat, from.lon, to.lat, to.lon); const minutes = estimateTravelTime(distanceKm, startTime, cityKey); @@ -98,9 +105,11 @@ export const predictRoute = (req, res) => { }; predictionCache.set(cacheKey, prediction); + console.log(`[predict.js] [${requestId}] Prediction successful. Result:`, prediction); return res.json({ ...prediction, cached: false }); } catch (error) { - console.error('Prediction error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[predict.js] [${requestId}] Prediction error: ${errorMessage}`, { error }); return res.status(500).json({ error: 'Internal server error', message: error?.message ?? String(error), diff --git a/backend/server.js b/backend/server.js index 9261793..f25b703 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,10 +9,22 @@ dotenv.config(); const app = express(); const PORT = process.env.PORT || 8000; +const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000') + .split(',') + .map(origin => origin.trim()); + +console.log(`[server.js]:${allowedOrigins}`) // Middleware app.use(cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3000', - credentials: true + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -66,6 +78,7 @@ app.use('*', (req, res) => { if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => { console.log(`🚀 GoPredict API Server running on port ${PORT}`); + console.log(`✅ CORS enabled for origins: ${allowedOrigins.join(', ')}`); console.log(`📊 Health check: http://localhost:${PORT}/api/health`); console.log(`🔮 Prediction endpoint: http://localhost:${PORT}/api/predict`); }); diff --git a/frontend/src/components/DateTimePicker.css b/frontend/src/components/DateTimePicker/DateTimePicker.css similarity index 82% rename from frontend/src/components/DateTimePicker.css rename to frontend/src/components/DateTimePicker/DateTimePicker.css index 6bfe7e9..55d8613 100644 --- a/frontend/src/components/DateTimePicker.css +++ b/frontend/src/components/DateTimePicker/DateTimePicker.css @@ -128,3 +128,31 @@ .react-datepicker__time-list::-webkit-scrollbar { display: none; /* Safari and Chrome */ } + +/* Center the datepicker on small screens */ +@media (max-width: 480px) { + .react-datepicker-portal { + @apply fixed inset-0 z-30 flex items-center justify-center bg-black/50; + } + .react-datepicker-portal .react-datepicker-popper { + /* Reset popper.js inline styles to allow flex centering */ + @apply static transform-none; + } + + /* Reduce calendar size on mobile */ + .react-datepicker { + @apply p-1; + } + .react-datepicker__header { + @apply p-1; + } + .react-datepicker__current-month { + @apply text-sm; + } + .react-datepicker__day-name { + @apply m-0.5 w-7; + } + .react-datepicker__day { + @apply m-0.5 h-7 w-7; + } +} diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker/DateTimePicker.tsx similarity index 100% rename from frontend/src/components/DateTimePicker.tsx rename to frontend/src/components/DateTimePicker/DateTimePicker.tsx diff --git a/frontend/src/components/LeafletMap.tsx b/frontend/src/components/LeafletMap.tsx index dc4593e..f31d747 100644 --- a/frontend/src/components/LeafletMap.tsx +++ b/frontend/src/components/LeafletMap.tsx @@ -1,6 +1,8 @@ -import { useEffect } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' import L from 'leaflet' import 'leaflet/dist/leaflet.css' +import {Loader2 } from 'lucide-react' +import {LeafletMapProps} from '@/types/LeafletMaps'; // Fix for default markers not showing in bundled environments @@ -31,131 +33,242 @@ const createCustomIcon = (color: string) => { }) } -export type GeoPoint = { - name?: string - lat: number - lon: number -} +// Create a small white dot icon for turn-by-turn markers +const createTurnIcon = () => { + const markerHtml = ` + + + ` -interface LeafletMapProps { - from?: GeoPoint | null - to?: GeoPoint | null - animateKey?: string | number + return L.divIcon({ + className: 'leaflet-turn-icon', + html: markerHtml, + iconSize: [12, 12], + iconAnchor: [6, 6], + }) } -export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) { - useEffect(() => { - const map = L.map('route-map', { - zoomControl: true, - }) +export default function LeafletMap({ from, to, animateKey, isPredicting }: LeafletMapProps) { + const [isRouteLoading, setIsRouteLoading] = useState(false) + const [routeError, setRouteError] = useState(null); - const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY - const tileUrl = apiKey - ? `https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=${apiKey}` - : 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + // Refs to hold Leaflet instances, preventing re-initialization on re-renders + const mapRef = useRef(null); + const routeLayerRef = useRef(null); + const markersRef = useRef([]); + const turnMarkersRef = useRef([]); + // --- Map Drawing and Animation Functions (SRP) --- - - const tiles = L.tileLayer(tileUrl, { - maxZoom: 19, - attribution: - apiKey - ? '© OpenMapTiles © OpenStreetMap contributors | © Geoapify' - : '© OpenStreetMap contributors', - }) - tiles.addTo(map) - - let markers: L.Marker[] = [] - let routeLayer: L.Polyline | L.GeoJSON | null = null - - const fitBoundsIfNeeded = () => { - const points: L.LatLngExpression[] = [] - if (from) points.push([from.lat, from.lon]) - if (to) points.push([to.lat, to.lon]) - if (points.length) { - const bounds = L.latLngBounds(points) - map.fitBounds(bounds.pad(0.25)) - } else { - map.setView([40.7128, -74.006], 11) + const clearTurnMarkers = useCallback(() => { + turnMarkersRef.current.forEach(m => m.remove()); + turnMarkersRef.current = []; + }, []); + + const animateRoute = useCallback((geoJsonData: any) => { + const map = mapRef.current; + if (!map) return; + + if (routeLayerRef.current) routeLayerRef.current.remove(); + clearTurnMarkers(); + + const allCoords = geoJsonData.geometry.coordinates.flat(1).map((c: number[]) => L.latLng(c[1], c[0])); + const animatedPolyline = L.polyline([], { color: '#2563eb', weight: 4, opacity: 0.95 }).addTo(map); + routeLayerRef.current = animatedPolyline; + + const steps = geoJsonData.properties?.legs?.[0]?.steps; + const turnPoints = (steps && steps.length > 1) + ? steps.slice(1).map((step: any) => ({ + index: step.from_index, + latlng: allCoords[step.from_index], + })).filter((turn: any) => turn.latlng) + : []; + + let nextTurnIndex = 0; + const turnIcon = createTurnIcon(); + const animationDuration = 750; + let startTime: number | null = null; + + const step = (timestamp: number) => { + if (!startTime) startTime = timestamp; + const progress = Math.min((timestamp - startTime) / animationDuration, 1); + const pointsToShow = Math.floor(progress * allCoords.length); + + if (pointsToShow > animatedPolyline.getLatLngs().length) { + animatedPolyline.setLatLngs(allCoords.slice(0, pointsToShow)); + + while (nextTurnIndex < turnPoints.length && turnPoints[nextTurnIndex].index <= pointsToShow) { + const turn = turnPoints[nextTurnIndex]; + turnMarkersRef.current.push(L.marker(turn.latlng, { icon: turnIcon }).addTo(map)); + nextTurnIndex++; + } } - } - const drawMarkers = () => { - markers.forEach(m => m.remove()) - markers = [] - if (from) { - const startMarker = L.marker([from.lat, from.lon], { - icon: createCustomIcon('#2563eb'), // Blue pin for start - }).addTo(map) - startMarker.bindPopup(`Start: ${from.name || 'Start Location'}`) - markers.push(startMarker) + if (progress < 1) { + requestAnimationFrame(step); + } else { + animatedPolyline.setLatLngs(allCoords); + const properties = geoJsonData.properties; + if (properties) { + const distanceKm = (properties.distance / 1000).toFixed(1); + const timeMinutes = Math.round(properties.time / 60); + animatedPolyline.bindPopup(`Route Details
Distance: ${distanceKm} km
Est. Time: ${timeMinutes} minutes`); + } } - if (to) { - const endMarker = L.marker([to.lat, to.lon], { - icon: createCustomIcon('#ef4444'), // Red pin for end - }).addTo(map) - endMarker.bindPopup(`End: ${to.name || 'End Location'}`) - markers.push(endMarker) + }; + requestAnimationFrame(step); + }, [clearTurnMarkers]); + + const fetchRoute = useCallback(async () => { + console.log(`[LeafletMap] Fetching route from ${from?.name ?? 'start'} to ${to?.name ?? 'end'}.`); + if (!from || !to) return; + + const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY; + if (!apiKey) { + // Fallback to straight line if no API key + if (mapRef.current) { // Guard against mapRef.current being null + if (routeLayerRef.current) routeLayerRef.current.remove(); + console.warn("[LeafletMap] No API key. Drawing a straight line as a fallback."); + routeLayerRef.current = L.polyline([[from.lat, from.lon], [to.lat, to.lon]], { color: '#2563eb', weight: 3, opacity: 0.85 }).addTo(mapRef.current); } + return; } - const drawStraight = () => { - if (!from || !to) return - if (routeLayer) routeLayer.remove() - routeLayer = L.polyline( - [ - [from.lat, from.lon], - [to.lat, to.lon], - ], - { color: '#2563eb', weight: 3, opacity: 0.85 } - ).addTo(map) - } + setIsRouteLoading(true); + clearTurnMarkers(); + setRouteError(null); + if (routeLayerRef.current) routeLayerRef.current.remove(); + + try { + const primaryRouteUrl = `https://api.geoapify.com/v1/routing?waypoints=${from.lat},${from.lon}|${to.lat},${to.lon}&mode=drive&format=geojson&waypoints.snapped=true&apiKey=${apiKey}`; + console.log("[LeafletMap] Primary API Request:", primaryRouteUrl); + const primaryRouteResponse = await fetch(primaryRouteUrl); + let routeData = await primaryRouteResponse.json(); + console.log("[LeafletMap] Primary API Response:", { status: primaryRouteResponse.status, ok: primaryRouteResponse.ok, data: routeData }); + + if (routeData.statusCode === 400) { + console.warn("[LeafletMap] Initial routing failed (status 400). Attempting fallback with reverse geocoding."); + const fromRevUrl = `https://api.geoapify.com/v1/geocode/reverse?lat=${from.lat}&lon=${from.lon}&apiKey=${apiKey}`; + const toRevUrl = `https://api.geoapify.com/v1/geocode/reverse?lat=${to.lat}&lon=${to.lon}&apiKey=${apiKey}`; + console.log("[LeafletMap] Fallback Geocode Requests:", { from: fromRevUrl, to: toRevUrl }); + + const fromPromise = fetch(fromRevUrl).then(res => res.json()); + const toPromise = fetch(toRevUrl).then(res => res.json()); + const [fromRev, toRev] = await Promise.all([fromPromise, toPromise]); + console.log("[LeafletMap] Fallback Geocode Responses:", { fromRev, toRev }); + + const correctedFrom = fromRev?.features?.[0]?.properties; + const correctedTo = toRev?.features?.[0]?.properties; - const fetchRoute = async () => { - if (!from || !to) return - const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY - if (!apiKey) { - drawStraight() - return + if (!correctedFrom || !correctedTo) { + console.error("[LeafletMap] Reverse geocoding failed for fallback.", { fromRev, toRev }); + throw new Error("Reverse geocoding failed."); + } + + const fallbackRouteUrl = `https://api.geoapify.com/v1/routing?waypoints=${correctedFrom.lat},${correctedFrom.lon}|${correctedTo.lat},${correctedTo.lon}&mode=drive&format=geojson&apiKey=${apiKey}`; + console.log("[LeafletMap] Fallback API Request:", fallbackRouteUrl); + const fallbackRouteResponse = await fetch(fallbackRouteUrl); + if (!fallbackRouteResponse.ok) throw new Error(`Fallback routing failed.`); + routeData = await fallbackRouteResponse.json(); + console.log("[LeafletMap] Fallback API Response:", { status: fallbackRouteResponse.status, ok: fallbackRouteResponse.ok, data: routeData }); } - try { - //EXAMPLE:https://api.geoapify.com/v1/routing?waypoints=40.7757145,-73.87336398511545|40.6604335,-73.8302749&mode=drive&apiKey=YOUR_API_KEY - const url = `https://api.geoapify.com/v1/routing?waypoints=${from.lat},${from.lon}|${to.lat},${to.lon}&mode=drive&format=geojson&apiKey=${apiKey}` - console.log(url); - const res = await fetch(url) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() - console.log('Geoapify route data:', data); - if (!data?.features?.[0]) throw new Error('No route') - if (routeLayer) routeLayer.remove() - routeLayer = L.geoJSON(data.features[0], { - style: { color: '#2563eb', weight: 4, opacity: 0.95 }, - }).addTo(map) - } catch { - drawStraight() + + if (!routeData?.features?.[0]) throw new Error("No route feature found in API response."); + + console.log("[LeafletMap] Route data received, starting animation."); + animateRoute(routeData.features[0]); + } catch (error) { + if (error instanceof Error) { + console.error("[LeafletMap] Final routing error:", error); + setRouteError(error.message); } + } finally { + setIsRouteLoading(false); } + }, [from, to, animateRoute, clearTurnMarkers]); - drawMarkers() - fitBoundsIfNeeded() - // Always draw something quickly, then try to replace with routed geometry - if (from && to) { - drawStraight() - fetchRoute() - } + // --- useEffect Hooks for Lifecycle Management --- + + // Effect for map initialization (runs once) + useEffect(() => { + if (mapRef.current) return; // Initialize map only once + console.log("[LeafletMap] Initializing Leaflet map component."); + mapRef.current = L.map('route-map', { zoomControl: true }); + const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY; + const tileUrl = apiKey + ? `https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=${apiKey}` + : 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + + L.tileLayer(tileUrl, { + maxZoom: 19, + attribution: apiKey ? '© OpenMapTiles © OpenStreetMap contributors | © Geoapify' : '© OpenStreetMap contributors', + }).addTo(mapRef.current); + + // Cleanup function to remove map on component unmount return () => { - map.remove() + console.log("[LeafletMap] Cleaning up and removing map instance."); + mapRef.current?.remove(); + mapRef.current = null; + }; + }, []); + + // Effect for updating markers and view when 'from' or 'to' change + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + console.log("[LeafletMap] Updating start/end markers and map view."); + + // Draw start/end markers + markersRef.current.forEach(m => m.remove()); + markersRef.current = []; + if (from) { + const startMarker = L.marker([from.lat, from.lon], { icon: createCustomIcon('#2563eb') }).addTo(map); + startMarker.bindPopup(`Start: ${from.name || 'Start Location'}`); + markersRef.current.push(startMarker); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [from?.lat, from?.lon, to?.lat, to?.lon, animateKey]) + if (to) { + const endMarker = L.marker([to.lat, to.lon], { icon: createCustomIcon('#ef4444') }).addTo(map); + endMarker.bindPopup(`End: ${to.name || 'End Location'}`); + markersRef.current.push(endMarker); + } + + // Adjust map view + const points: L.LatLngExpression[] = []; + if (from) points.push([from.lat, from.lon]); + if (to) points.push([to.lat, to.lon]); + + if (points.length > 0) { + map.fitBounds(L.latLngBounds(points).pad(0.25)); + } else { + map.setView([40.7128, -74.006], 11); // Default view + } + }, [from, to]); + + // Effect for fetching and drawing the route + useEffect(() => { + if (from && to) { + fetchRoute(); + } else { + // Clear route if 'from' or 'to' is missing + if (routeLayerRef.current) routeLayerRef.current.remove(); + clearTurnMarkers(); + } + }, [from, to, animateKey, fetchRoute, clearTurnMarkers]); return ( -
+
- Route Preview + {isPredicting ? ( + Calculating route... + ) : ( + routeError ? + Route not found : + Route Preview + )}
{from && to ? ( {from.name ?? 'Start'} → {to.name ?? 'End'} @@ -163,7 +276,15 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) { Select locations to preview )}
-
+
+
+ {isRouteLoading && ( +
+ +
+ )} +
+
) } \ No newline at end of file diff --git a/frontend/src/components/LocationSearch.tsx b/frontend/src/components/LocationSearch.tsx index bb335f8..047ed07 100644 --- a/frontend/src/components/LocationSearch.tsx +++ b/frontend/src/components/LocationSearch.tsx @@ -16,7 +16,7 @@ interface LocationSearchProps { label: string; value: string; onChange: (val: string) => void; - onLocationSelect: (location: Location) => void; + onLocationSelect: (location: Location | null) => void; selectedLocation?: Location | null; placeholder?: string; className?: string; @@ -46,7 +46,13 @@ const SF_LOCATIONS: Location[] = [ { id: "sf_mission", name: "Mission District, SF", lat: 37.7599, lon: -122.4148 }, { id: "sf_soma", name: "SoMa, SF", lat: 37.7749, lon: -122.4194 }, { id: "sf_marina", name: "Marina District, SF", lat: 37.8024, lon: -122.4368 }, - { id: "sf_sfo_airport", name: "SFO Airport, SF", lat: 37.6213, lon: -122.3790 }, +//? Wrong data possibly { id: "sf_sfo_airport", name: "SFO Airport, SF", lat: 37.6213, lon: -122.3790 }, +{ id: "sf_sfo_airport", name: "SFO Airport, SF", lat: 37.6163, lon: -122.3863 }, //! had coorinates of runway which is not publicaly accessible + + + + + { id: "sf_oakland_airport", name: "Oakland Airport, SF", lat: 37.7126, lon: -122.2196 }, ]; @@ -109,7 +115,7 @@ export function LocationSearch({ // If user clears the input, clear the selected location if (!newValue.trim()) { - onLocationSelect(null as any); + onLocationSelect(null); } }; @@ -174,7 +180,7 @@ export function LocationSearch({ className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 p-0" onClick={() => { onChange(''); - onLocationSelect(null as any); + onLocationSelect(null); }} > × @@ -200,7 +206,7 @@ export function LocationSearch({
{location.name}
- {location.lat.toFixed(4)}, {location.lon.toFixed(4)} + {location.lat.toFixed(8)}, {location.lon.toFixed(8)}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 269b2bc..12b26c0 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, useRef } from "react"; import { LocationSearch } from "@/components/LocationSearch"; import LeafletMap from "@/components/LeafletMap"; -import { DateTimePicker } from "@/components/DateTimePicker"; +import { DateTimePicker } from "@/components/DateTimePicker/DateTimePicker"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ui/theme-toggle"; import { predictTravelTime } from "@/lib/api"; -import { Clock, MapPin, Car, AlertTriangle } from "lucide-react"; +import { Clock, MapPin, Car, AlertTriangle ,Loader2} from "lucide-react"; import Footer from "@/components/Footer"; import { motion, AnimatePresence } from "framer-motion"; @@ -49,10 +49,12 @@ export default function Home() { const [travelDate, setTravelDate] = useState(new Date()); const [predicted, setPredicted] = useState(null); const [animKey, setAnimKey] = useState(0); + const [currentCity, setCurrentCity] = useState<"new_york" | "san_francisco">( "new_york" ); const [isLoading, setIsLoading] = useState(false); + const [isPredicting, setIsPredicting] = useState(false); const [warning, setWarning] = useState(""); // Add this line // Update city when location changes @@ -107,6 +109,7 @@ export default function Home() { } setIsLoading(true); + setIsPredicting(true); const isMobile = window.innerWidth <= 768; // Validate that both locations are within the same city @@ -120,6 +123,7 @@ export default function Home() { "Cross-city travel is not supported. Please select locations within the same city (New York or San Francisco)" ); setIsLoading(false); + setIsPredicting(false); return; } @@ -131,6 +135,7 @@ export default function Home() { city: currentCity, }); + console.log('Prediction API response:', response); if (typeof response.minutes === "number" && isFinite(response.minutes)) { setPredicted(response.minutes); if (isMobile) { @@ -138,23 +143,22 @@ export default function Home() { } else { setAnimKey((k) => k + 1); } - setIsLoading(false); return; } } catch (error) { console.error("Prediction API error:", error); + // Clear previous prediction if API fails, so fallback is used or it's reset + setPredicted(null); + } finally { + // Fallback calculation if prediction is still null after API call + if (predicted === null) { + const km = haversineKm(fromLocation, toLocation); + const minutes = estimateMinutes(km, travelDate); + setPredicted(minutes); + } + setIsLoading(false); + setIsPredicting(false); } - - // Fallback calculation - const km = haversineKm(fromLocation, toLocation); - const minutes = estimateMinutes(km, travelDate); - setPredicted(minutes); - if (isMobile) { - setTimeout(() => setAnimKey((k) => k + 1), 900); - } else { - setAnimKey((k) => k + 1); - } - setIsLoading(false); }; const resultRef = useRef(null); @@ -347,6 +351,7 @@ export default function Home() { from={fromLocation} to={toLocation} animateKey={`${animKey}-${fromLocation?.id}-${toLocation?.id}`} + isPredicting={isPredicting} />
@@ -459,7 +464,14 @@ export default function Home() { disabled={!fromLocation || !toLocation || !travelDate || isLoading} className="h-12 w-full rounded-lg bg-primary text-primary-foreground shadow-soft transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50" > - Predict Travel Time + {isLoading ? ( + <> + + Predicting... + + ) : ( + 'Predict Travel Time' + )} diff --git a/frontend/src/types/LeafletMaps.ts b/frontend/src/types/LeafletMaps.ts new file mode 100644 index 0000000..cbf8016 --- /dev/null +++ b/frontend/src/types/LeafletMaps.ts @@ -0,0 +1,13 @@ + +type GeoPoint = { + name?: string + lat: number + lon: number +} + +export interface LeafletMapProps { + from?: GeoPoint | null + to?: GeoPoint | null + animateKey?: string | number + isPredicting?: boolean +} diff --git a/netlify.toml b/netlify.toml index 04a0239..fce6c72 100644 --- a/netlify.toml +++ b/netlify.toml @@ -5,7 +5,7 @@ SECRETS_SCAN_OMIT_KEYS = "VITE_API_URL,VITE_GEOAPIFY_API_KEY" [build] base = "frontend" command = "pnpm install && pnpm run build" -publish = "dist" +publish = "/dist" [[redirects]] from = "/*"