Appearance
F-018: Filter Refinement
Status: ✅ Done · Priority: P1 · Branch:
feature/F-018-filter-refinement· Updated: Mar 5, 2026
Summary
Comprehensive filter UX overhaul and map/grid view merge:
- Price range — Dual-thumb range slider ($0–$5,000+, step $50) with range labels
- Property type — Single-select dropdown → multi-select with checkboxes
- Bedrooms — Single-select dropdown → multi-select with checkboxes
- Postal code — New text input filter by postal code prefix (e.g., H2T)
- Area range — Dual-thumb range slider (0–3,000+ ft², step 50) with range labels
Available date— Removed (felt out of place, backend API param kept)- Filter sheet — All filters in a sheet/drawer (bottom sheet mobile, centered dialog desktop)
- Grid/Map view toggle — Merged map into listings page with shared filters
- Bug fixes — Fixed /mo/mo price display in map popups, improved stacked popup image transitions
Requirements
- [x] Price filter: dual-thumb range slider with range labels ($0–$5,000+)
- [x] Property type filter: multi-select with checkboxes (like neighborhoods)
- [x] Bedrooms filter: multi-select with checkboxes (like neighborhoods)
- [x] Backend supports comma-separated
propertyTypeandbedroomsparams - [x] All filters URL-synced (bookmarkable)
- [x] Backward-compatible with existing single-value URLs
- [x] Bilingual labels (FR/EN)
- [x] Postal code prefix filter
- [x]
Available date filter— removed from UI, backend kept - [x] Area range filter: dual-thumb range slider with range labels (0–3,000+ ft²)
- [x] Filter sheet: bottom sheet (mobile), centered dialog (desktop)
- [x] Grid/Map view toggle on listings page
- [x] Map uses same filters as grid, bounds-based query (200 limit)
- [x] Old
/maproute redirects to/listings?view=map - [x] Fix /mo/mo price display bug in map popups
- [x] Preload images in stacked popup for smooth navigation
Design
Price Range
- Popover with dual-thumb
<Slider>($0–$5,000, step $50) + two number inputs for precise entry - User types in dollars, URL stores cents (×100)
- Slider fires on
onValueCommit(mouse/touch release), inputs use 500ms debounce - Button label: "$500 – $2,000" when active, "Price" when inactive
- Uses
components/ui/slider.tsx— shadcn-style wrapper around Radix UI Slider
Property Type Multi-Select
- Popover + checkboxes iterating all 9 property types
- Button label: "{N} selected" or "Type"
- Clear button: "All Types"
- Backend:
propertyType=apartment,condo→inArray()query - Reuses the neighborhoods popover pattern
Bedrooms Multi-Select
- Popover + checkboxes: Studio, 1 bed, 2 beds, 3 beds, 4+ beds
- Backend:
bedrooms=0,1,2→inArray()for exact values,gte(4)for 4+ - Mixed:
bedrooms=1,4→bedrooms IN (1) OR bedrooms >= 4
URL Format
?propertyType=apartment,condo&bedrooms=0,1,2&minPrice=80000&maxPrice=150000Files Modified
| File | Change |
|---|---|
services/api/src/routes/listings.ts | Multi-value propertyType (inArray) and bedrooms (string type, comma-split, 4+ OR logic) |
services/web/lib/api.ts | propertyType: string[], bedrooms: number[], generalized array serialization, removed availableBy |
services/web/hooks/use-listings-filters.ts | Parse comma-separated arrays from URL, view/setView (separate from API filters), clearFilters preserves view, removed availableBy |
services/web/components/listings/listing-filters.tsx | All filters in vertical sheet layout, price/area as inline dual-thumb sliders with range labels, removed number inputs |
services/web/components/ui/slider.tsx | NEW — shadcn-style Radix UI Slider wrapper (dual-thumb support) |
services/web/components/ui/sheet.tsx | NEW — Radix Dialog as sheet (bottom sheet mobile, centered dialog desktop) |
services/web/app/[locale]/listings/page.tsx | Grid/Map view toggle, filter sheet, conditional grid/map rendering, separate queries |
services/web/app/[locale]/map/page.tsx | Replaced with client redirect to /listings?view=map |
services/web/components/layout/header.tsx | Removed map nav link |
services/web/components/map/listing-map.tsx | Fixed /mo/mo price duplication in popup |
services/web/components/map/stacked-popup.tsx | Fixed /mo/mo, added image preloading for smooth navigation |
services/web/messages/en.json | Added filter sheet, view toggle, slider label keys |
services/web/messages/fr.json | Same |
Discussion Notes
Mar 5, 2026
- Merged map into listings page with grid/map view toggle — removes need for separate
/maproute - Fixed
/mo/moprice duplication bug in map popups (formatPriceShort already includes /mo suffix) - Added image preloading in stacked popup for smooth listing navigation
Mar 4, 2026
- Moved all filters into sheet/drawer (bottom sheet mobile, centered dialog desktop)
- Price and area sliders changed from popover+inputs to inline range sliders with labels
- Range labels show "+" at max values ($5,000+, 3,000+ ft²) to indicate uncapped range
- Removed available date filter from UI (backend param kept)
- Neighborhoods filter already uses multi-select popover — reuse same pattern for property type and bedrooms
- Price preset buckets too rigid — users want custom ranges
- Backend already supports
minPrice/maxPriceindependently, only needspropertyTypeandbedroomsmulti-value support bedrooms=4means "4+" (gte), combined with exact values via OR
Implementation Notes
Backend (services/api/src/routes/listings.ts)
propertyTypeparam: split by comma, useinArray()for multiple values,eq()for singlebedroomsparam: changed fromintegertostringtype. Splits CSV, maps to numbers. Exact values useinArray(), value 4+ usesgte(4). Multiple conditions combined withor()- Backward-compatible: single values like
?propertyType=apartmentor?bedrooms=2still work
Frontend
- API client (
lib/api.ts):propertyType→string[],bedrooms→number[]. Generalized array serialization (all arrays join with commas) - Filter hook (
hooks/use-listings-filters.ts): ParsespropertyTypeandbedroomsas comma-separated arrays from URL params. Addednumber[]to value union type - Filter UI (
components/listings/listing-filters.tsx): Property type and bedrooms use popover + checkbox pattern (same as neighborhoods). Price ($0–$5,000) and area (0–3,000 ft²) use inline dual-thumb range sliders with number inputs below — no popover wrapper. Slider fires ononValueCommit(mouse/touch release), inputs use 500ms debounce. User types dollars, URL stores cents (×100).useEffectsyncs local state on external clear. Responsive layout:flex-colon mobile,flex-row flex-wrapon desktop (md:breakpoint) - Slider component (
components/ui/slider.tsx): shadcn-style wrapper aroundradix-uiSlider. Renders one thumb per value (dual-thumb forvalue={[min, max]}) - Sheet component (
components/ui/sheet.tsx): Bottom sheet using Radix Dialog, slide-up animation, drag handle visual, close button. Used for mobile filter overlay - Mobile filter sheet: On mobile (
<md), filters are hidden from inline layout. A floating "Filters" button (fixed bottom-right) opens a bottom sheet with all filters stacked vertically. Active filter indicator badge on button - Translations: Added
minPrice/maxPrice/postalCode/price/area/filterButtonkeys, removed 8 preset price range keys (under800,range800_1200, etc.) from both EN and FR
Postal Code Filter
- Backend:
ILIKEprefix match onaddress_postal_code(strips whitespace, uppercases). E.g.,?postalCode=H2TmatchesH2T 1S6 - Frontend: Text input, uppercases on type, maxLength 7
- All 4,578 active listings (100%) have a postal code — no data gap
Available Date Filter (removed from UI)
- Backend:
lte(listings.availableDate, new Date(query.availableBy))— API param kept for backward compatibility, but not exposed in filter UI - Removed: felt out of place in filter bar, low user value
Area Range Filter
- Backend:
gte(listings.areaSqFt, minArea)/lte(listings.areaSqFt, maxArea)— same pattern as price - Frontend: Inline dual-thumb range slider (0–3,000+ ft²) with range labels below
- 81% of active listings have area data
Filter Sheet UX
- All filters moved into a sheet triggered by "Filters" button (next to search bar)
- Mobile: bottom sheet with slide-up animation (
inset-x-0 bottom-0 rounded-t-xl) - Desktop: centered dialog with fade+zoom animation (
md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2) - Built on Radix Dialog primitives with
tw-animate-cssfor enter/exit animations - Visible title, instant apply (no apply button needed), clear all button
- Filters button shows indicator badge when filters are active
Grid/Map View Merge
- View toggle in listings page header (LayoutGrid / MapPin icons)
viewURL param (grid|map) managed separately fromListingsFilters— not sent to API- Grid query: paginated (limit 20), enabled only in grid view
- Map query: bounds-based (limit 200, no pagination), enabled only in map view, includes all active filters
clearFilterspreserves the currentviewparam- Old
/mappage replaced with client-side redirect to/listings?view=map - Map nav link removed from header
Bug Fixes
/mo/moin map popups:formatPriceShort()already returns$X/mo, but popup templates appended an extra/mo. Fixed in bothlisting-map.tsx(single popup) andstacked-popup.tsx(stacked popup)- Stacked popup image smoothness: Added
useEffectto preload all listing images when stacked popup opens. Added CSStransition-opacityon images for visual smoothness