From 8509a0326f1daedb61149cc458efc9e27464852d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 8 Feb 2026 13:16:32 +0000 Subject: [PATCH] Add frontend POI management and travel time display POIManager component in FilterPanel for creating/deleting POIs and triggering distance calculations. PropertyCard shows travel time badges (walk/cycle/transit) per POI. Map renders POI locations as red markers. API client extended with POST body support for POI endpoints. --- frontend/src/App.tsx | 24 ++- frontend/src/components/FilterPanel.tsx | 27 +++- frontend/src/components/Map.tsx | 31 +++- frontend/src/components/POIManager.tsx | 187 +++++++++++++++++++++++ frontend/src/components/PropertyCard.tsx | 58 ++++++- frontend/src/services/apiClient.ts | 13 +- frontend/src/services/index.ts | 1 + frontend/src/services/poiService.ts | 64 ++++++++ frontend/src/types/index.ts | 19 +++ 9 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/POIManager.tsx create mode 100644 frontend/src/services/poiService.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0a6b41..a8f4b8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,8 +15,8 @@ import { StreamingProgressBar } from './components/StreamingProgressBar'; import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet'; import { Button } from './components/ui/button'; import { Filter } from 'lucide-react'; -import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types'; -import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type StreamingProgress } from '@/services'; +import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI } from '@/types'; +import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services'; function App() { const [listingData, setListingData] = useState(null); @@ -30,6 +30,7 @@ function App() { const [mobileFilterOpen, setMobileFilterOpen] = useState(false); const [highlightedProperty, setHighlightedProperty] = useState(null); const [streamingProgress, setStreamingProgress] = useState(null); + const [userPOIs, setUserPOIs] = useState([]); // Ref to track accumulated features during streaming const accumulatedFeaturesRef = useRef([]); @@ -70,6 +71,12 @@ function App() { }); }, [user, taskID]); + // Load user's POIs + useEffect(() => { + if (!user) return; + fetchUserPOIs(user).then(setUserPOIs).catch(() => {}); + }, [user]); + // Load listings function - used by both auto-load and manual submit const loadListings = useCallback(async (parameters: ParameterValues) => { if (!user) return; @@ -221,6 +228,7 @@ function App() { listingData={listingData} queryParameters={queryParameters} onPropertyClick={handlePropertyClick} + pois={userPOIs} /> )} @@ -243,6 +251,14 @@ function App() { setTaskID(null); }; + const handlePOITaskCreated = (taskId: string) => { + setTaskID(taskId); + // Refresh POI list in case new ones were created + if (user) { + fetchUserPOIs(user).then(setUserPOIs).catch(() => {}); + } + }; + return (
{/* Header */} @@ -261,6 +277,8 @@ function App() { onMetricChange={handleMetricChange} isLoading={isLoading} listingCount={listingData?.features.length} + user={user} + onTaskCreated={handlePOITaskCreated} />
@@ -278,6 +296,8 @@ function App() { onMetricChange={handleMetricChange} isLoading={isLoading} listingCount={listingData?.features.length} + user={user} + onTaskCreated={handlePOITaskCreated} /> diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index 3810079..aaf3477 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -8,8 +8,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion"; -import { Loader2, Filter, RefreshCw } from "lucide-react"; +import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react"; import { ScrollArea } from "./ui/scroll-area"; +import { POIManager } from "./POIManager"; +import type { AuthUser } from "@/auth/types"; export enum Metric { qmprice = 'qmprice', @@ -69,6 +71,8 @@ interface FilterPanelProps { onMetricChange?: (metric: Metric) => void; isLoading?: boolean; listingCount?: number; + user?: AuthUser; + onTaskCreated?: (taskId: string) => void; } const formSchema = z.object({ @@ -90,7 +94,7 @@ const formSchema = z.object({ type FormValues = z.infer; -export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount }: FilterPanelProps) { +export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated }: FilterPanelProps) { const [availableFromRawInput, setAvailableFromRawInput] = useState("now"); const [selectedFurnishTypes, setSelectedFurnishTypes] = useState([]); @@ -530,6 +534,25 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount + + {/* Points of Interest */} + {user && ( + + + + + Points of Interest + + + + + + + )} diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 22af8d0..ffbc095 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -8,7 +8,7 @@ import "../assets/Map.css"; import { Metric, type ParameterValues } from "./Parameters"; import { PropertyCard } from "./PropertyCard"; import { ScrollArea } from "./ui/scroll-area"; -import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from "@/types"; +import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types"; import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants"; import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes"; import { clone, percentile, calculateColorStops } from "@/utils/mapUtils"; @@ -40,6 +40,7 @@ interface MapProps { listingData: GeoJSONFeatureCollection; queryParameters: ParameterValues | null; onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void; + pois?: POI[]; } interface FilterState { @@ -58,6 +59,7 @@ export function Map(props: MapProps) { const updateTimeoutRef = useRef(null); const isMapLoadedRef = useRef(false); const lastDataLengthRef = useRef(0); + const poiMarkersRef = useRef([]); const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice }; if (props.queryParameters) { @@ -237,6 +239,33 @@ export function Map(props: MapProps) { }; }, [data, updateHeatmap]); + // Update POI markers when pois prop changes + useEffect(() => { + if (!mapRef.current || !isMapLoadedRef.current) return; + + // Remove existing markers + poiMarkersRef.current.forEach(m => m.remove()); + poiMarkersRef.current = []; + + if (!props.pois) return; + + for (const poi of props.pois) { + const el = document.createElement('div'); + el.className = 'poi-marker'; + el.style.cssText = 'width:24px;height:24px;background:#ef4444;border:2px solid white;border-radius:50%;box-shadow:0 2px 4px rgba(0,0,0,0.3);cursor:pointer;'; + el.title = poi.name; + + const marker = new mapboxgl.Marker({ element: el }) + .setLngLat([poi.longitude, poi.latitude]) + .setPopup(new mapboxgl.Popup({ offset: 12 }).setHTML( + `
${poi.name}
${poi.address}
` + )) + .addTo(mapRef.current); + + poiMarkersRef.current.push(marker); + } + }, [props.pois]); + function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) { const svg_height = 280, svg_width = 80; d3.select('#svg').selectAll('*').remove(); diff --git a/frontend/src/components/POIManager.tsx b/frontend/src/components/POIManager.tsx new file mode 100644 index 0000000..58cdbca --- /dev/null +++ b/frontend/src/components/POIManager.tsx @@ -0,0 +1,187 @@ +import { useState, useEffect } from 'react'; +import { MapPin, Plus, Trash2, Calculator, Loader2 } from 'lucide-react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import type { AuthUser } from '@/auth/types'; +import type { POI } from '@/types'; +import { fetchUserPOIs, createPOI, deletePOI, triggerPOICalculation } from '@/services/poiService'; + +interface POIManagerProps { + user: AuthUser; + listingType: 'RENT' | 'BUY'; + onTaskCreated?: (taskId: string) => void; +} + +export function POIManager({ user, listingType, onTaskCreated }: POIManagerProps) { + const [pois, setPois] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [lat, setLat] = useState(''); + const [lng, setLng] = useState(''); + const [calculating, setCalculating] = useState(null); + const [selectedModes, setSelectedModes] = useState(['WALK', 'BICYCLE', 'TRANSIT']); + + useEffect(() => { + fetchUserPOIs(user).then(setPois).catch(() => {}); + }, [user]); + + const handleCreate = async () => { + if (!name || !lat || !lng) return; + try { + const poi = await createPOI(user, { + name, + address: address || name, + latitude: parseFloat(lat), + longitude: parseFloat(lng), + }); + setPois(prev => [...prev, poi]); + setIsAdding(false); + setName(''); + setAddress(''); + setLat(''); + setLng(''); + } catch { + // silently fail + } + }; + + const handleDelete = async (poiId: number) => { + try { + await deletePOI(user, poiId); + setPois(prev => prev.filter(p => p.id !== poiId)); + } catch { + // silently fail + } + }; + + const handleCalculate = async (poiId: number) => { + setCalculating(poiId); + try { + const result = await triggerPOICalculation(user, poiId, selectedModes, listingType); + onTaskCreated?.(result.task_id); + } catch { + // silently fail + } finally { + setCalculating(null); + } + }; + + const toggleMode = (mode: string) => { + setSelectedModes(prev => + prev.includes(mode) ? prev.filter(m => m !== mode) : [...prev, mode] + ); + }; + + return ( +
+ {/* POI List */} + {pois.map(poi => ( +
+ + {poi.name} + + +
+ ))} + + {/* Travel mode toggles */} + {pois.length > 0 && ( +
+ {['WALK', 'BICYCLE', 'TRANSIT'].map(mode => ( + + ))} +
+ )} + + {/* Add POI Form */} + {isAdding ? ( +
+ setName(e.target.value)} + className="h-7 text-xs" + /> + setAddress(e.target.value)} + className="h-7 text-xs" + /> +
+ setLat(e.target.value)} + className="h-7 text-xs" + /> + setLng(e.target.value)} + className="h-7 text-xs" + /> +
+
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 638ae9a..64b3f2a 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -1,6 +1,51 @@ -import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building } from 'lucide-react'; +import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react'; import { Button } from './ui/button'; -import type { PropertyProperties } from '@/types'; +import type { PropertyProperties, POIDistanceInfo } from '@/types'; + +function formatDuration(seconds: number): string { + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; +} + +function TravelModeIcon({ mode }: { mode: string }) { + switch (mode) { + case 'WALK': return ; + case 'BICYCLE': return ; + case 'TRANSIT': return ; + default: return null; + } +} + +function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) { + if (!distances || distances.length === 0) return null; + + // Group by POI name + const byPoi = new Map(); + for (const d of distances) { + const existing = byPoi.get(d.poi_name) || []; + existing.push(d); + byPoi.set(d.poi_name, existing); + } + + return ( +
+ {Array.from(byPoi.entries()).map(([poiName, dists]) => ( +
+ {poiName}: + {dists.map(d => ( + + + {formatDuration(d.duration_seconds)} + + ))} +
+ ))} +
+ ); +} interface PropertyCardProps { property: PropertyProperties; @@ -91,6 +136,7 @@ export function PropertyCard({ {property.agency} + ); @@ -171,6 +217,14 @@ export function PropertyCard({ Seen {lastSeenDays} days ago + {/* POI Distances */} + {property.poi_distances && property.poi_distances.length > 0 && ( +
+
Travel times
+ +
+ )} + {/* Price history */} {property.price_history.length > 1 && (
diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index b03c719..f2dcf62 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -6,6 +6,7 @@ import { ApiError } from '@/types'; export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; params?: Record; + body?: unknown; } /** @@ -35,7 +36,7 @@ export async function apiRequest( endpoint: string, options: RequestOptions = {} ): Promise { - const { method = 'GET', params } = options; + const { method = 'GET', params, body } = options; let url = endpoint; if (params) { @@ -45,13 +46,19 @@ export async function apiRequest( } } - const response = await fetch(url, { + const fetchOptions: RequestInit = { method, headers: { Authorization: `Bearer ${user.accessToken}`, 'Content-Type': 'application/json', }, - }); + }; + + if (body !== undefined) { + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); if (!response.ok) { throw new ApiError(`Error: ${response.status}`, response.status); diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 6807b63..775123c 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -4,3 +4,4 @@ export { fetchListingGeoJSON, refreshListings } from './listingService'; export { streamListingGeoJSON, type StreamingProgress } from './streamingService'; export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService'; export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService'; +export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService'; diff --git a/frontend/src/services/poiService.ts b/frontend/src/services/poiService.ts new file mode 100644 index 0000000..7529735 --- /dev/null +++ b/frontend/src/services/poiService.ts @@ -0,0 +1,64 @@ +// POI API service for managing Points of Interest + +import type { AuthUser } from '@/auth/types'; +import type { POI, POIDistanceInfo } from '@/types'; +import { apiRequest } from './apiClient'; + +export async function fetchUserPOIs(user: AuthUser): Promise { + return apiRequest(user, '/api/poi'); +} + +export async function createPOI( + user: AuthUser, + data: { name: string; address: string; latitude: number; longitude: number } +): Promise { + return apiRequest(user, '/api/poi', { + method: 'POST', + body: data, + }); +} + +export async function updatePOI( + user: AuthUser, + poiId: number, + data: { name?: string; address?: string; latitude?: number; longitude?: number } +): Promise { + return apiRequest(user, `/api/poi/${poiId}`, { + method: 'PUT', + body: data, + }); +} + +export async function deletePOI(user: AuthUser, poiId: number): Promise { + await apiRequest(user, `/api/poi/${poiId}`, { method: 'DELETE' }); +} + +export async function triggerPOICalculation( + user: AuthUser, + poiId: number, + travelModes: string[], + listingType: 'RENT' | 'BUY', + listingIds?: number[] +): Promise<{ task_id: string; message: string }> { + return apiRequest(user, `/api/poi/${poiId}/calculate`, { + method: 'POST', + body: { + travel_modes: travelModes, + listing_type: listingType, + listing_ids: listingIds, + }, + }); +} + +export async function fetchPOIDistances( + user: AuthUser, + listingId: number, + listingType: 'RENT' | 'BUY' = 'RENT' +): Promise { + return apiRequest(user, '/api/poi/distances', { + params: { + listing_id: listingId, + listing_type: listingType, + }, + }); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 52fdd4d..f0976a4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -21,6 +21,7 @@ export interface PropertyProperties { photo_thumbnail: string; price_history: PropertyPriceHistory[]; listing_type?: 'RENT' | 'BUY'; + poi_distances?: POIDistanceInfo[]; } export interface PropertyFeature { @@ -96,3 +97,21 @@ export class ApiError extends Error { this.name = 'ApiError'; } } + +// POI types +export interface POI { + id: number; + name: string; + address: string; + latitude: number; + longitude: number; + created_at: string; +} + +export interface POIDistanceInfo { + poi_id: number; + poi_name: string; + travel_mode: 'WALK' | 'BICYCLE' | 'TRANSIT'; + duration_seconds: number; + distance_meters: number; +}