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