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.
This commit is contained in:
parent
bb489c2032
commit
8509a0326f
9 changed files with 414 additions and 10 deletions
|
|
@ -15,8 +15,8 @@ import { StreamingProgressBar } from './components/StreamingProgressBar';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
||||||
import { Button } from './components/ui/button';
|
import { Button } from './components/ui/button';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI } from '@/types';
|
||||||
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type StreamingProgress } from '@/services';
|
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
||||||
|
|
@ -30,6 +30,7 @@ function App() {
|
||||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
||||||
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
||||||
|
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
|
||||||
|
|
||||||
// Ref to track accumulated features during streaming
|
// Ref to track accumulated features during streaming
|
||||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||||
|
|
@ -70,6 +71,12 @@ function App() {
|
||||||
});
|
});
|
||||||
}, [user, taskID]);
|
}, [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
|
// Load listings function - used by both auto-load and manual submit
|
||||||
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
@ -221,6 +228,7 @@ function App() {
|
||||||
listingData={listingData}
|
listingData={listingData}
|
||||||
queryParameters={queryParameters}
|
queryParameters={queryParameters}
|
||||||
onPropertyClick={handlePropertyClick}
|
onPropertyClick={handlePropertyClick}
|
||||||
|
pois={userPOIs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -243,6 +251,14 @@ function App() {
|
||||||
setTaskID(null);
|
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 (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -261,6 +277,8 @@ function App() {
|
||||||
onMetricChange={handleMetricChange}
|
onMetricChange={handleMetricChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
listingCount={listingData?.features.length}
|
listingCount={listingData?.features.length}
|
||||||
|
user={user}
|
||||||
|
onTaskCreated={handlePOITaskCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,6 +296,8 @@ function App() {
|
||||||
onMetricChange={handleMetricChange}
|
onMetricChange={handleMetricChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
listingCount={listingData?.features.length}
|
listingCount={listingData?.features.length}
|
||||||
|
user={user}
|
||||||
|
onTaskCreated={handlePOITaskCreated}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
|
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 { ScrollArea } from "./ui/scroll-area";
|
||||||
|
import { POIManager } from "./POIManager";
|
||||||
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
|
||||||
export enum Metric {
|
export enum Metric {
|
||||||
qmprice = 'qmprice',
|
qmprice = 'qmprice',
|
||||||
|
|
@ -69,6 +71,8 @@ interface FilterPanelProps {
|
||||||
onMetricChange?: (metric: Metric) => void;
|
onMetricChange?: (metric: Metric) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
listingCount?: number;
|
listingCount?: number;
|
||||||
|
user?: AuthUser;
|
||||||
|
onTaskCreated?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
|
@ -90,7 +94,7 @@ const formSchema = z.object({
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount }: FilterPanelProps) {
|
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated }: FilterPanelProps) {
|
||||||
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||||
|
|
||||||
|
|
@ -530,6 +534,25 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Points of Interest */}
|
||||||
|
{user && (
|
||||||
|
<AccordionItem value="poi">
|
||||||
|
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="h-3.5 w-3.5" />
|
||||||
|
Points of Interest
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<POIManager
|
||||||
|
user={user}
|
||||||
|
listingType={watchedListingType as 'RENT' | 'BUY'}
|
||||||
|
onTaskCreated={onTaskCreated}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import "../assets/Map.css";
|
||||||
import { Metric, type ParameterValues } from "./Parameters";
|
import { Metric, type ParameterValues } from "./Parameters";
|
||||||
import { PropertyCard } from "./PropertyCard";
|
import { PropertyCard } from "./PropertyCard";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
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 { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
||||||
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
||||||
import { clone, percentile, calculateColorStops } from "@/utils/mapUtils";
|
import { clone, percentile, calculateColorStops } from "@/utils/mapUtils";
|
||||||
|
|
@ -40,6 +40,7 @@ interface MapProps {
|
||||||
listingData: GeoJSONFeatureCollection;
|
listingData: GeoJSONFeatureCollection;
|
||||||
queryParameters: ParameterValues | null;
|
queryParameters: ParameterValues | null;
|
||||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||||
|
pois?: POI[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
|
|
@ -58,6 +59,7 @@ export function Map(props: MapProps) {
|
||||||
const updateTimeoutRef = useRef<number | null>(null);
|
const updateTimeoutRef = useRef<number | null>(null);
|
||||||
const isMapLoadedRef = useRef<boolean>(false);
|
const isMapLoadedRef = useRef<boolean>(false);
|
||||||
const lastDataLengthRef = useRef<number>(0);
|
const lastDataLengthRef = useRef<number>(0);
|
||||||
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||||
|
|
||||||
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
|
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
|
||||||
if (props.queryParameters) {
|
if (props.queryParameters) {
|
||||||
|
|
@ -237,6 +239,33 @@ export function Map(props: MapProps) {
|
||||||
};
|
};
|
||||||
}, [data, updateHeatmap]);
|
}, [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(
|
||||||
|
`<div style="padding:4px 8px"><strong>${poi.name}</strong><br/><span style="color:#666;font-size:12px">${poi.address}</span></div>`
|
||||||
|
))
|
||||||
|
.addTo(mapRef.current);
|
||||||
|
|
||||||
|
poiMarkersRef.current.push(marker);
|
||||||
|
}
|
||||||
|
}, [props.pois]);
|
||||||
|
|
||||||
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
|
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
|
||||||
const svg_height = 280, svg_width = 80;
|
const svg_height = 280, svg_width = 80;
|
||||||
d3.select('#svg').selectAll('*').remove();
|
d3.select('#svg').selectAll('*').remove();
|
||||||
|
|
|
||||||
187
frontend/src/components/POIManager.tsx
Normal file
187
frontend/src/components/POIManager.tsx
Normal file
|
|
@ -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<POI[]>([]);
|
||||||
|
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<number | null>(null);
|
||||||
|
const [selectedModes, setSelectedModes] = useState<string[]>(['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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* POI List */}
|
||||||
|
{pois.map(poi => (
|
||||||
|
<div key={poi.id} className="flex items-center gap-2 p-2 rounded-md border text-sm">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="flex-1 truncate font-medium">{poi.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => handleCalculate(poi.id)}
|
||||||
|
disabled={calculating === poi.id}
|
||||||
|
>
|
||||||
|
{calculating === poi.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Calculator className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(poi.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Travel mode toggles */}
|
||||||
|
{pois.length > 0 && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{['WALK', 'BICYCLE', 'TRANSIT'].map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleMode(mode)}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded-md border transition-colors ${
|
||||||
|
selectedModes.includes(mode)
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-muted border-input'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode === 'WALK' ? 'Walk' : mode === 'BICYCLE' ? 'Cycle' : 'Transit'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add POI Form */}
|
||||||
|
{isAdding ? (
|
||||||
|
<div className="space-y-2 p-2 border rounded-md">
|
||||||
|
<Input
|
||||||
|
placeholder="Name (e.g., Office)"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Address (optional)"
|
||||||
|
value={address}
|
||||||
|
onChange={e => setAddress(e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="Latitude"
|
||||||
|
value={lat}
|
||||||
|
onChange={e => setLat(e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="Longitude"
|
||||||
|
value={lng}
|
||||||
|
onChange={e => setLng(e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" className="h-7 text-xs flex-1" onClick={handleCreate}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setIsAdding(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-7 text-xs"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add POI
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { 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 <Footprints className="h-3 w-3" />;
|
||||||
|
case 'BICYCLE': return <Bike className="h-3 w-3" />;
|
||||||
|
case 'TRANSIT': return <Train className="h-3 w-3" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
||||||
|
if (!distances || distances.length === 0) return null;
|
||||||
|
|
||||||
|
// Group by POI name
|
||||||
|
const byPoi = new Map<string, POIDistanceInfo[]>();
|
||||||
|
for (const d of distances) {
|
||||||
|
const existing = byPoi.get(d.poi_name) || [];
|
||||||
|
existing.push(d);
|
||||||
|
byPoi.set(d.poi_name, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
||||||
|
<div key={poiName} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
|
<span className="font-medium">{poiName}:</span>
|
||||||
|
{dists.map(d => (
|
||||||
|
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
||||||
|
<TravelModeIcon mode={d.travel_mode} />
|
||||||
|
{formatDuration(d.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PropertyCardProps {
|
interface PropertyCardProps {
|
||||||
property: PropertyProperties;
|
property: PropertyProperties;
|
||||||
|
|
@ -91,6 +136,7 @@ export function PropertyCard({
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{property.agency}</span>
|
<span className="truncate">{property.agency}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<POIDistanceBadges distances={property.poi_distances || []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -171,6 +217,14 @@ export function PropertyCard({
|
||||||
<span>Seen {lastSeenDays} days ago</span>
|
<span>Seen {lastSeenDays} days ago</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* POI Distances */}
|
||||||
|
{property.poi_distances && property.poi_distances.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
||||||
|
<POIDistanceBadges distances={property.poi_distances} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Price history */}
|
{/* Price history */}
|
||||||
{property.price_history.length > 1 && (
|
{property.price_history.length > 1 && (
|
||||||
<div className="mt-3 pt-3 border-t">
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ApiError } from '@/types';
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
params?: Record<string, string | number | boolean | Date | undefined>;
|
params?: Record<string, string | number | boolean | Date | undefined>;
|
||||||
|
body?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,7 +36,7 @@ export async function apiRequest<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestOptions = {}
|
options: RequestOptions = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { method = 'GET', params } = options;
|
const { method = 'GET', params, body } = options;
|
||||||
|
|
||||||
let url = endpoint;
|
let url = endpoint;
|
||||||
if (params) {
|
if (params) {
|
||||||
|
|
@ -45,13 +46,19 @@ export async function apiRequest<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${user.accessToken}`,
|
Authorization: `Bearer ${user.accessToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(`Error: ${response.status}`, response.status);
|
throw new ApiError(`Error: ${response.status}`, response.status);
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ export { fetchListingGeoJSON, refreshListings } from './listingService';
|
||||||
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
|
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
|
||||||
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
|
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
|
||||||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||||
|
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';
|
||||||
|
|
|
||||||
64
frontend/src/services/poiService.ts
Normal file
64
frontend/src/services/poiService.ts
Normal file
|
|
@ -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<POI[]> {
|
||||||
|
return apiRequest<POI[]>(user, '/api/poi');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPOI(
|
||||||
|
user: AuthUser,
|
||||||
|
data: { name: string; address: string; latitude: number; longitude: number }
|
||||||
|
): Promise<POI> {
|
||||||
|
return apiRequest<POI>(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<POI> {
|
||||||
|
return apiRequest<POI>(user, `/api/poi/${poiId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePOI(user: AuthUser, poiId: number): Promise<void> {
|
||||||
|
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<POIDistanceInfo[]> {
|
||||||
|
return apiRequest<POIDistanceInfo[]>(user, '/api/poi/distances', {
|
||||||
|
params: {
|
||||||
|
listing_id: listingId,
|
||||||
|
listing_type: listingType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ export interface PropertyProperties {
|
||||||
photo_thumbnail: string;
|
photo_thumbnail: string;
|
||||||
price_history: PropertyPriceHistory[];
|
price_history: PropertyPriceHistory[];
|
||||||
listing_type?: 'RENT' | 'BUY';
|
listing_type?: 'RENT' | 'BUY';
|
||||||
|
poi_distances?: POIDistanceInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PropertyFeature {
|
export interface PropertyFeature {
|
||||||
|
|
@ -96,3 +97,21 @@ export class ApiError extends Error {
|
||||||
this.name = 'ApiError';
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue