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 { 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<GeoJSONFeatureCollection | null>(null);
|
||||
|
|
@ -30,6 +30,7 @@ function App() {
|
|||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
||||
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
||||
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
|
||||
|
||||
// Ref to track accumulated features during streaming
|
||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
|
|
@ -261,6 +277,8 @@ function App() {
|
|||
onMetricChange={handleMetricChange}
|
||||
isLoading={isLoading}
|
||||
listingCount={listingData?.features.length}
|
||||
user={user}
|
||||
onTaskCreated={handlePOITaskCreated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -278,6 +296,8 @@ function App() {
|
|||
onMetricChange={handleMetricChange}
|
||||
isLoading={isLoading}
|
||||
listingCount={listingData?.features.length}
|
||||
user={user}
|
||||
onTaskCreated={handlePOITaskCreated}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -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<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 [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||
|
||||
|
|
@ -530,6 +534,25 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount
|
|||
</div>
|
||||
</AccordionContent>
|
||||
</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>
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
const isMapLoadedRef = useRef<boolean>(false);
|
||||
const lastDataLengthRef = useRef<number>(0);
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||
|
||||
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(
|
||||
`<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) {
|
||||
const svg_height = 280, svg_width = 80;
|
||||
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 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 {
|
||||
property: PropertyProperties;
|
||||
|
|
@ -91,6 +136,7 @@ export function PropertyCard({
|
|||
</span>
|
||||
<span className="truncate">{property.agency}</span>
|
||||
</div>
|
||||
<POIDistanceBadges distances={property.poi_distances || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -171,6 +217,14 @@ export function PropertyCard({
|
|||
<span>Seen {lastSeenDays} days ago</span>
|
||||
</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 */}
|
||||
{property.price_history.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ApiError } from '@/types';
|
|||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
params?: Record<string, string | number | boolean | Date | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,7 +36,7 @@ export async function apiRequest<T>(
|
|||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', params } = options;
|
||||
const { method = 'GET', params, body } = options;
|
||||
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
|
|
@ -45,13 +46,19 @@ export async function apiRequest<T>(
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue