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:
Viktor Barzin 2026-02-28 16:12:09 +00:00
parent de47e2cca8
commit 4053c0c759
No known key found for this signature in database
GPG key ID: 0EB088298288D958
2 changed files with 812 additions and 0 deletions

View 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&sup2;)</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&sup2;)</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 &pound;/m&sup2;</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 &pound;/m&sup2;</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 };

View 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>
);
}