diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c35cb6..56aacb1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ function App() { const [, setActiveCardFeature] = useState(null); const [showReviewMode, setShowReviewMode] = useState(false); const [selectedListingId, setSelectedListingId] = useState(null); + const [bottomSheetSnap, setBottomSheetSnap] = useState("148px"); // Decision state (like/dislike) const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); @@ -487,7 +488,12 @@ function App() { {/* Filter & Review FABs */} -
+
- )} -
- - - ); -}; - -export default ActiveQuery; diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index fe9f22d..111f992 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -554,7 +554,7 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, ) : ( <> - Apply Filters + Show Matching Listings )} @@ -565,8 +565,9 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, disabled={isLoading} > - Refresh Data + Scrape New from Rightmove +

Triggers a live crawl — may take several minutes

); diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index fc1dd5a..5afec72 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -1,7 +1,7 @@ import * as d3 from "d3"; import mapboxgl from "mapbox-gl"; import 'mapbox-gl/dist/mapbox-gl.css'; -import { useEffect, useRef, useMemo, useCallback } from "react"; +import { useEffect, useRef, useMemo, useCallback, useState } from "react"; import { Crosshair } from "lucide-react"; import { createRoot } from 'react-dom/client'; import "../assets/Map.css"; @@ -32,6 +32,10 @@ interface MapProps { export function Map(props: MapProps) { const data = props.listingData; + const [showMapHint, setShowMapHint] = useState(() => { + return localStorage.getItem('map-hint-dismissed') !== 'true'; + }); + const mapRef = useRef(null); const mapContainerRef = useRef(null); const heatmapRef = useRef(null); @@ -421,6 +425,20 @@ export function Map(props: MapProps) { )} + {!props.isPickingPOI && showMapHint && ( +
+ Click on colored areas to view properties + +
+ )}
diff --git a/frontend/src/components/MobileBottomSheet.tsx b/frontend/src/components/MobileBottomSheet.tsx index a69e467..3337fe6 100644 --- a/frontend/src/components/MobileBottomSheet.tsx +++ b/frontend/src/components/MobileBottomSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Drawer } from 'vaul'; import { MapPin, PoundSterling } from 'lucide-react'; import { SwipeableCardRow } from './SwipeableCardRow'; @@ -11,6 +11,7 @@ interface MobileBottomSheetProps { highlightedPropertyUrl?: string | null; onActiveListingChange?: (feature: PropertyFeature | null) => void; poiMetricSelection?: { poiId: number; travelMode: string } | null; + onSnapChange?: (snap: string | number | null) => void; } function formatCurrency(value: number): string { @@ -24,10 +25,16 @@ export function MobileBottomSheet({ highlightedPropertyUrl, onActiveListingChange, poiMetricSelection, + onSnapChange, }: MobileBottomSheetProps) { const [snap, setSnap] = useState("148px"); const [activeCardIndex, setActiveCardIndex] = useState(0); + // Notify parent when snap changes + useEffect(() => { + onSnapChange?.(snap); + }, [snap, onSnapChange]); + const features = listingData?.features ?? []; const stats = useMemo(() => { diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 7b3a8b9..e4c1767 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -86,7 +86,7 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist ); } -function CardCarousel({ photos }: { photos: string[] }) { +function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) { const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); const [selectedIndex, setSelectedIndex] = useState(0); @@ -105,7 +105,7 @@ function CardCarousel({ photos }: { photos: string[] }) { return ( Property @@ -120,7 +120,7 @@ function CardCarousel({ photos }: { photos: string[] }) {
{`Photo @@ -187,7 +187,10 @@ export function PropertyCard({ {/* Photo carousel */}
{(property.photos?.length || property.photo_thumbnail) ? ( - + ) : null}
@@ -248,7 +251,7 @@ export function PropertyCard({ {property.photo_thumbnail && ( Property )} diff --git a/frontend/src/components/PropertyCardCompact.tsx b/frontend/src/components/PropertyCardCompact.tsx index 932e7da..28e945c 100644 --- a/frontend/src/components/PropertyCardCompact.tsx +++ b/frontend/src/components/PropertyCardCompact.tsx @@ -37,7 +37,7 @@ export function PropertyCardCompact({ {property.photo_thumbnail && ( Property )} diff --git a/frontend/src/components/SavedView.tsx b/frontend/src/components/SavedView.tsx deleted file mode 100644 index bb3fe5d..0000000 --- a/frontend/src/components/SavedView.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useMemo } from 'react'; -import { Virtuoso } from 'react-virtuoso'; -import { Heart } from 'lucide-react'; -import { PropertyCard } from './PropertyCard'; -import type { GeoJSONFeatureCollection, PropertyFeature, DecisionType } from '@/types'; - -interface SavedViewProps { - listingData: GeoJSONFeatureCollection; - getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; -} - -function getListingId(feature: PropertyFeature): number { - const parts = feature.properties.url.split('/'); - return parseInt(parts[parts.length - 1], 10); -} - -export function SavedView({ listingData, getDecision }: SavedViewProps) { - const savedFeatures = useMemo(() => { - return listingData.features.filter((f) => { - const id = getListingId(f); - const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; - return getDecision(id, type) === 'liked'; - }); - }, [listingData, getDecision]); - - if (savedFeatures.length === 0) { - return ( -
-
- -

No saved properties yet

-

- Use the Review mode to swipe through properties and save ones you like. -

-
-
- ); - } - - return ( -
-
- {savedFeatures.length} saved {savedFeatures.length === 1 ? 'property' : 'properties'} -
- ( -
- -
- )} - /> -
- ); -} diff --git a/frontend/src/components/SwipeCard.tsx b/frontend/src/components/SwipeCard.tsx index 4ed15c4..c9883f6 100644 --- a/frontend/src/components/SwipeCard.tsx +++ b/frontend/src/components/SwipeCard.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useCallback, useEffect } from 'react'; +import { useRef, useState, useCallback, useEffect, useMemo } from 'react'; import { animated, useSpring } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; import useEmblaCarousel from 'embla-carousel-react'; @@ -20,6 +20,11 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC const p = feature.properties; const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : []; + const prefersReducedMotion = useMemo( + () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, + [], + ); + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); const [selectedPhoto, setSelectedPhoto] = useState(0); @@ -41,6 +46,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC scale: 1 - stackIndex * 0.05, opacity: stackIndex <= 2 ? 1 : 0, config: { tension: 300, friction: 25 }, + immediate: prefersReducedMotion, })); const bind = useDrag( @@ -118,7 +124,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
{photos.map((url, i) => (
- {`Photo + {`Property
))}
@@ -144,7 +150,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
) : photos.length === 1 ? ( - Property + {`${p.rooms}-bed, ) : null} +
+ + Dislike +
- +
+ + Undo +
- +
+ + Skip +
+
+ + Like +
+ + )} + + {/* Onboarding overlay */} + {showOnboarding && ( +
+
+
+ + Swipe left + Dislike +
+
+ + Swipe up + Skip +
+
+ + Swipe right + Like +
+
)} diff --git a/frontend/src/index.css b/frontend/src/index.css index d6d113b..ebccdef 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -153,3 +153,12 @@ display: none; } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 0a12a15..700bd10 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/perfcollector.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"} \ No newline at end of file