feat: create FilterBar and FilterChips components
Add a horizontal FilterBar component with popover-based dropdowns for Price, Beds, Size, and a "More Filters" panel with advanced options (price/m2, furnishing, district, date, POI travel filters). Action buttons (Show Listings / Scrape New) are aligned to the right. Add FilterChips component that renders active (non-default) filter values as removable pills below the filter bar.
This commit is contained in:
parent
de47e2cca8
commit
4053c0c759
2 changed files with 812 additions and 0 deletions
686
frontend/src/components/FilterBar.tsx
Normal file
686
frontend/src/components/FilterBar.tsx
Normal file
|
|
@ -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<typeof formSchema>;
|
||||
|
||||
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<number, POITravelFilter>;
|
||||
onPoiTravelFiltersChange: (filters: Record<number, POITravelFilter>) => 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<FurnishType[]>([]);
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState('now');
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex items-center gap-2 px-4 h-12 bg-muted/40 border-b shrink-0"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* ── Price Popover ── */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={triggerCls}>
|
||||
{priceLabel}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-3 space-y-3" align="start">
|
||||
<p className="text-xs font-medium mb-2">Price (GBP)</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── Beds Popover ── */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={triggerCls}>
|
||||
{bedsLabel}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-3 space-y-3" align="start">
|
||||
<p className="text-xs font-medium mb-2">Bedrooms</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="10"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── Size Popover ── */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={triggerCls}>
|
||||
{sizeLabel}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-3 space-y-3" align="start">
|
||||
<p className="text-xs font-medium mb-2">Size (m²)</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── More Filters Popover ── */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={triggerCls}>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
More{moreCount > 0 && ` (${moreCount})`}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm font-semibold">Advanced Filters</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Max Size */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max Size (m²)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Last Seen Days */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="last_seen_days"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Last Seen (days)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="28"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price per sqm min */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_price_per_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min £/m²</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price per sqm max */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_price_per_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max £/m²</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Furnishing (rent only) */}
|
||||
{watchedListingType === ListingType.RENT && (
|
||||
<div>
|
||||
<FormLabel className="text-xs">Furnishing</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{[
|
||||
{ value: FurnishType.FURNISHED, label: 'Furnished' },
|
||||
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
|
||||
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => toggleFurnishType(option.value)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
selectedFurnishTypes.includes(option.value)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted border-input'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* District */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="district"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">District</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g., Westminster, Camden"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Comma-separated list of districts
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Available From (rent only) */}
|
||||
{watchedListingType === ListingType.RENT && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="available_from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Available From</FormLabel>
|
||||
<FormControl>
|
||||
<Calendar29
|
||||
onSelect={field.onChange}
|
||||
selected={field.value}
|
||||
rawInputValue={availableFromRawInput}
|
||||
onChangeRawInputValue={setAvailableFromRawInput}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* POI section */}
|
||||
{user && userPOIs.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium flex items-center gap-1.5 mb-2">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
Points of Interest
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{userPOIs.map((poi) => {
|
||||
const filter = poiTravelFilters?.[poi.id];
|
||||
const travelMode = filter?.travelMode ?? 'WALK';
|
||||
const maxMinutes = filter?.maxMinutes;
|
||||
return (
|
||||
<div key={poi.id} className="flex items-center gap-1.5">
|
||||
<span className="text-xs truncate w-16 shrink-0" title={poi.name}>
|
||||
{poi.name}
|
||||
</span>
|
||||
<Select
|
||||
value={travelMode}
|
||||
onValueChange={(value) => {
|
||||
onPoiTravelFiltersChange({
|
||||
...poiTravelFilters,
|
||||
[poi.id]: {
|
||||
travelMode: value as 'WALK' | 'BICYCLE' | 'TRANSIT',
|
||||
maxMinutes,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-[88px] shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WALK">Walk</SelectItem>
|
||||
<SelectItem value="BICYCLE">Bicycle</SelectItem>
|
||||
<SelectItem value="TRANSIT">Transit</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="\u2014"
|
||||
className="h-7 text-xs w-14 shrink-0"
|
||||
min={1}
|
||||
value={maxMinutes ?? ''}
|
||||
onChange={(e) => {
|
||||
onPoiTravelFiltersChange({
|
||||
...poiTravelFilters,
|
||||
[poi.id]: {
|
||||
travelMode,
|
||||
maxMinutes: e.target.value ? Number(e.target.value) : undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">min</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POI Manager (authenticated users) */}
|
||||
{user && (
|
||||
<div className={userPOIs.length === 0 ? 'pt-2 border-t' : ''}>
|
||||
{userPOIs.length === 0 && (
|
||||
<p className="text-xs font-medium flex items-center gap-1.5 mb-2">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
Points of Interest
|
||||
</p>
|
||||
)}
|
||||
<POIManager
|
||||
user={user}
|
||||
listingType={watchedListingType as 'RENT' | 'BUY'}
|
||||
onTaskCreated={onTaskCreated}
|
||||
onStartPicking={() => {
|
||||
onPoiPickerActiveChange(true);
|
||||
onPickedPoiLocationChange(null);
|
||||
}}
|
||||
pickedLocation={pickedPoiLocation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply button inside More Filters */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── Spacer ── */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* ── Action Buttons (right side) ── */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 bg-teal-600 hover:bg-teal-700 text-white text-xs gap-1.5"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Show Listings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => handleFormSubmit('fetch-data')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Scrape New
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export getValues helper type for external access
|
||||
export type { FilterBarProps };
|
||||
126
frontend/src/components/FilterChips.tsx
Normal file
126
frontend/src/components/FilterChips.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 py-1.5 border-b bg-muted/20">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(chip.key)}
|
||||
className="hover:bg-primary/20 rounded-full p-0.5 transition-colors"
|
||||
aria-label={`Remove ${chip.label} filter`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue