diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 0000000..74581e8 --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -0,0 +1,686 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { ChevronDown, Loader2, RefreshCw, Search, MapPin, SlidersHorizontal } from 'lucide-react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { ScrollArea } from './ui/scroll-area'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormDescription } from './ui/form'; +import { Calendar29 } from './ui/DatePicker'; +import { POIManager } from './POIManager'; +import { + type ParameterValues, + DEFAULT_FILTER_VALUES, + ListingType, + FurnishType, + Metric, +} from './FilterPanel'; +import type { AuthUser } from '@/auth/types'; +import type { POI, POITravelFilter } from '@/types'; + +// ── Zod schema (same as FilterPanel) ── +const formSchema = z.object({ + listing_type: z.nativeEnum(ListingType, { required_error: 'Listing Type is required' }), + min_bedrooms: z.number().min(0).max(10).optional(), + max_bedrooms: z.number().min(0).max(10).optional(), + max_price: z.number().optional(), + min_price: z.number().min(0).optional(), + min_sqm: z.number().optional(), + max_sqm: z.number().optional(), + min_price_per_sqm: z.number().optional(), + max_price_per_sqm: z.number().optional(), + last_seen_days: z.number().min(0).optional(), + available_from: z.date(), + district: z.string(), + furnish_types: z.array(z.nativeEnum(FurnishType)).optional(), +}); + +type FormValues = z.infer; + +const PRICE_BOUNDS = { + [ListingType.RENT]: { min: 0, max: 10000, step: 50 }, + [ListingType.BUY]: { min: 0, max: 2000000, step: 10000 }, +} as const; + +// ── Props ── +interface FilterBarProps { + onSubmit: (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => void; + isLoading: boolean; + user: AuthUser; + userPOIs: POI[]; + onPOIsChange: (pois: POI[]) => void; + poiTravelFilters: Record; + onPoiTravelFiltersChange: (filters: Record) => void; + listingType: ListingType; + onListingTypeChange: (type: ListingType) => void; + poiPickerActive: boolean; + onPoiPickerActiveChange: (active: boolean) => void; + pickedPoiLocation: { lat: number; lng: number } | null; + onPickedPoiLocationChange: (loc: { lat: number; lng: number } | null) => void; + currentMetric: Metric; + onTaskCreated?: (taskId: string) => void; +} + +// ── Helpers ── +function formatPrice(v: number): string { + if (v >= 1_000_000) return `\u00A3${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `\u00A3${(v / 1_000).toFixed(0)}k`; + return `\u00A3${v}`; +} + +/** Read current ParameterValues from the form state (merges metric and furnish) */ +function readFormParams( + values: FormValues, + metric: Metric, + selectedFurnishTypes: FurnishType[], +): ParameterValues { + return { + ...values, + metric, + furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined, + }; +} + +// ── FilterBar ── +export function FilterBar({ + onSubmit, + isLoading, + user, + userPOIs, + poiTravelFilters, + onPoiTravelFiltersChange, + listingType, + onListingTypeChange, + poiPickerActive, + onPoiPickerActiveChange, + pickedPoiLocation, + onPickedPoiLocationChange, + currentMetric, + onTaskCreated, +}: FilterBarProps) { + const [selectedFurnishTypes, setSelectedFurnishTypes] = useState([]); + const [availableFromRawInput, setAvailableFromRawInput] = useState('now'); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + listing_type: DEFAULT_FILTER_VALUES.listing_type, + min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms, + max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms, + min_price: DEFAULT_FILTER_VALUES.min_price, + max_price: DEFAULT_FILTER_VALUES.max_price, + min_sqm: DEFAULT_FILTER_VALUES.min_sqm, + max_sqm: undefined, + min_price_per_sqm: undefined, + max_price_per_sqm: undefined, + last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days, + available_from: new Date(), + district: '', + }, + }); + + const watchedListingType = form.watch('listing_type'); + + // Sync listing type with parent + useEffect(() => { + if (watchedListingType !== listingType) { + onListingTypeChange(watchedListingType); + } + }, [watchedListingType, listingType, onListingTypeChange]); + + // Sync parent listing type changes back into form + useEffect(() => { + if (listingType !== form.getValues('listing_type')) { + form.setValue('listing_type', listingType); + } + }, [listingType, form]); + + // Price defaults when listing type changes + useEffect(() => { + if (watchedListingType === ListingType.BUY) { + form.setValue('min_price', 300000); + form.setValue('max_price', 600000); + } else { + form.setValue('min_price', 2000); + form.setValue('max_price', 3000); + } + if (watchedListingType === ListingType.BUY) { + setSelectedFurnishTypes([]); + } + }, [watchedListingType, form]); + + const handleFormSubmit = useCallback( + (action: 'fetch-data' | 'visualize') => { + return form.handleSubmit((values) => { + onSubmit(action, readFormParams(values, currentMetric, selectedFurnishTypes)); + })(); + }, + [form, onSubmit, currentMetric, selectedFurnishTypes], + ); + + /** Public getter so App can read current form values (e.g. for FilterChips) */ + const getValues = useCallback((): ParameterValues => { + return readFormParams(form.getValues(), currentMetric, selectedFurnishTypes); + }, [form, currentMetric, selectedFurnishTypes]); + + const toggleFurnishType = (type: FurnishType) => { + setSelectedFurnishTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type], + ); + }; + + // Watched values for trigger labels + const minPrice = form.watch('min_price'); + const maxPrice = form.watch('max_price'); + const minBeds = form.watch('min_bedrooms'); + const maxBeds = form.watch('max_bedrooms'); + const minSqm = form.watch('min_sqm'); + + // Price label + const priceLabel = (() => { + const lo = minPrice ?? 0; + const hi = maxPrice; + if (lo === 0 && !hi) return 'Price'; + if (!hi) return `${formatPrice(lo)}+`; + return `${formatPrice(lo)} \u2013 ${formatPrice(hi)}`; + })(); + + // Beds label + const bedsLabel = (() => { + const lo = minBeds ?? 0; + const hi = maxBeds ?? 10; + if (lo === 0 && hi >= 10) return 'Beds'; + if (hi >= 10) return `${lo}+`; + if (lo === hi) return `${lo} bed`; + return `${lo}-${hi}`; + })(); + + // Size label + const sizeLabel = minSqm && minSqm > 0 ? `${minSqm}+ m\u00B2` : 'Size'; + + // Check if "More Filters" has active values + const moreCount = (() => { + let c = 0; + const v = form.getValues(); + if (v.max_sqm) c++; + if (v.min_price_per_sqm) c++; + if (v.max_price_per_sqm) c++; + if (selectedFurnishTypes.length > 0) c++; + if (v.district) c++; + if (v.last_seen_days !== undefined && v.last_seen_days !== DEFAULT_FILTER_VALUES.last_seen_days) c++; + return c; + })(); + + // Trigger button base class + const triggerCls = + 'text-xs font-medium px-3 py-1.5 rounded-md border bg-background hover:bg-muted inline-flex items-center gap-1 whitespace-nowrap h-8'; + + return ( +
+ e.preventDefault()} + > + {/* ── Price Popover ── */} + + + + + +

Price (GBP)

+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + ( + + Max + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + +
+
+ + {/* ── Beds Popover ── */} + + + + + +

Bedrooms

+
+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + ( + + Max + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> +
+ +
+
+ + {/* ── Size Popover ── */} + + + + + +

Size (m²)

+ ( + + Min + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + +
+
+ + {/* ── More Filters Popover ── */} + + + + + + +
+

Advanced Filters

+ +
+ {/* Max Size */} + ( + + Max Size (m²) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Last Seen Days */} + ( + + Last Seen (days) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Price per sqm min */} + ( + + Min £/m² + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Price per sqm max */} + ( + + Max £/m² + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> +
+ + {/* Furnishing (rent only) */} + {watchedListingType === ListingType.RENT && ( +
+ Furnishing +
+ {[ + { value: FurnishType.FURNISHED, label: 'Furnished' }, + { value: FurnishType.PART_FURNISHED, label: 'Part' }, + { value: FurnishType.UNFURNISHED, label: 'Unfurn.' }, + ].map((option) => ( + + ))} +
+
+ )} + + {/* District */} + ( + + District + + + + + Comma-separated list of districts + + + )} + /> + + {/* Available From (rent only) */} + {watchedListingType === ListingType.RENT && ( + ( + + Available From + + + + + )} + /> + )} + + {/* POI section */} + {user && userPOIs.length > 0 && ( +
+

+ + Points of Interest +

+
+ {userPOIs.map((poi) => { + const filter = poiTravelFilters?.[poi.id]; + const travelMode = filter?.travelMode ?? 'WALK'; + const maxMinutes = filter?.maxMinutes; + return ( +
+ + {poi.name} + + + { + onPoiTravelFiltersChange({ + ...poiTravelFilters, + [poi.id]: { + travelMode, + maxMinutes: e.target.value ? Number(e.target.value) : undefined, + }, + }); + }} + /> + min +
+ ); + })} +
+
+ )} + + {/* POI Manager (authenticated users) */} + {user && ( +
+ {userPOIs.length === 0 && ( +

+ + Points of Interest +

+ )} + { + onPoiPickerActiveChange(true); + onPickedPoiLocationChange(null); + }} + pickedLocation={pickedPoiLocation} + /> +
+ )} + + {/* Apply button inside More Filters */} + +
+
+
+
+ + {/* ── Spacer ── */} +
+ + {/* ── Action Buttons (right side) ── */} + + + + + + ); +} + +// Re-export getValues helper type for external access +export type { FilterBarProps }; diff --git a/frontend/src/components/FilterChips.tsx b/frontend/src/components/FilterChips.tsx new file mode 100644 index 0000000..fd295e9 --- /dev/null +++ b/frontend/src/components/FilterChips.tsx @@ -0,0 +1,126 @@ +import { X } from 'lucide-react'; +import type { ParameterValues } from './FilterPanel'; +import { FurnishType } from './FilterPanel'; + +interface FilterChipsProps { + values: ParameterValues; + defaults: ParameterValues; + onRemove: (key: keyof ParameterValues) => void; +} + +/** Format a price value for display */ +function fmtPrice(v: number): string { + if (v >= 1_000_000) return `\u00A3${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `\u00A3${(v / 1_000).toFixed(0)}k`; + return `\u00A3${v}`; +} + +/** Label for a furnish type enum value */ +function furnishLabel(ft: FurnishType): string { + switch (ft) { + case FurnishType.FURNISHED: return 'Furnished'; + case FurnishType.PART_FURNISHED: return 'Part Furnished'; + case FurnishType.UNFURNISHED: return 'Unfurnished'; + default: return String(ft); + } +} + +type ChipDef = { key: keyof ParameterValues; label: string }; + +function buildChips(values: ParameterValues, defaults: ParameterValues): ChipDef[] { + const chips: ChipDef[] = []; + + // Price range + const priceChanged = + (values.min_price !== undefined && values.min_price !== defaults.min_price) || + (values.max_price !== undefined && values.max_price !== defaults.max_price); + if (priceChanged) { + const lo = values.min_price ?? 0; + const hi = values.max_price; + chips.push({ + key: 'min_price', + label: hi ? `${fmtPrice(lo)} \u2013 ${fmtPrice(hi)}` : `${fmtPrice(lo)}+`, + }); + } + + // Bedrooms + const bedsChanged = + (values.min_bedrooms !== undefined && values.min_bedrooms !== defaults.min_bedrooms) || + (values.max_bedrooms !== undefined && values.max_bedrooms !== defaults.max_bedrooms); + if (bedsChanged) { + const lo = values.min_bedrooms ?? 0; + const hi = values.max_bedrooms ?? 10; + if (hi >= 10) { + chips.push({ key: 'min_bedrooms', label: `${lo}+ beds` }); + } else if (lo === hi) { + chips.push({ key: 'min_bedrooms', label: `${lo} beds` }); + } else { + chips.push({ key: 'min_bedrooms', label: `${lo}-${hi} beds` }); + } + } + + // Min size + if (values.min_sqm !== undefined && values.min_sqm !== defaults.min_sqm) { + chips.push({ key: 'min_sqm', label: `${values.min_sqm}+ m\u00B2` }); + } + + // Max size + if (values.max_sqm !== undefined && values.max_sqm !== defaults.max_sqm) { + chips.push({ key: 'max_sqm', label: `\u2264${values.max_sqm} m\u00B2` }); + } + + // Price per sqm + if (values.min_price_per_sqm !== undefined && values.min_price_per_sqm !== defaults.min_price_per_sqm) { + chips.push({ key: 'min_price_per_sqm', label: `\u2265\u00A3${values.min_price_per_sqm}/m\u00B2` }); + } + if (values.max_price_per_sqm !== undefined && values.max_price_per_sqm !== defaults.max_price_per_sqm) { + chips.push({ key: 'max_price_per_sqm', label: `\u2264\u00A3${values.max_price_per_sqm}/m\u00B2` }); + } + + // District + if (values.district && values.district !== defaults.district) { + chips.push({ key: 'district', label: values.district }); + } + + // Furnishing + if (values.furnish_types && values.furnish_types.length > 0) { + chips.push({ + key: 'furnish_types', + label: values.furnish_types.map(furnishLabel).join(', '), + }); + } + + // Last seen days + if (values.last_seen_days !== undefined && values.last_seen_days !== defaults.last_seen_days) { + chips.push({ key: 'last_seen_days', label: `Last ${values.last_seen_days}d` }); + } + + return chips; +} + +export function FilterChips({ values, defaults, onRemove }: FilterChipsProps) { + const chips = buildChips(values, defaults); + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ ); +}