Fix crime & add actual listings
Some checks failed
CI / Check (push) Failing after 4m1s
Build and publish Docker image / build-and-push (push) Failing after 4m10s

This commit is contained in:
Andras Schmelczer 2026-05-17 11:12:25 +01:00
parent 017902b8e6
commit ebe7bbb51d
34 changed files with 2014 additions and 172754 deletions

View file

@ -126,9 +126,6 @@ function ProductDemoVideo() {
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
{t('home.productDemoLabel')}
</h2>
<div
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
isMobile ? 'mx-auto max-w-sm' : ''

View file

@ -15,6 +15,7 @@ import type {
FeatureMeta,
Bounds,
MapFlyToOptions,
ActualListing,
} from '../../types';
import {
@ -41,6 +42,7 @@ interface MapProps {
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
pois: POI[];
actualListings?: ActualListing[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
colorRange: [number, number] | null;
@ -77,6 +79,20 @@ interface MapProps {
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
}
interface Dimensions {
width: number;
@ -263,6 +279,7 @@ export default memo(function Map({
postcodeData,
usePostcodeView,
pois,
actualListings = EMPTY_ACTUAL_LISTINGS,
onViewChange,
viewFeature,
colorRange,
@ -442,6 +459,8 @@ export default memo(function Map({
layers,
popupInfo,
clearPopupInfo,
listingPopup,
clearListingPopup,
hoverPosition,
countRange,
postcodeCountRange,
@ -453,6 +472,7 @@ export default memo(function Map({
usePostcodeView,
zoom: viewState.zoom,
pois,
actualListings,
viewFeature,
colorRange,
filterRange,
@ -677,6 +697,77 @@ export default memo(function Map({
)}
</div>
)}
{listingPopup && (
<div
className="pointer-events-auto absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white max-w-[280px]"
style={{
left: listingPopup.x,
top: listingPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
}}
onMouseLeave={clearListingPopup}
>
<button
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearListingPopup}
>
<CloseIcon className="w-3 h-3" />
</button>
<a
href={listingPopup.listing.listing_url}
target="_blank"
rel="noopener noreferrer"
className="block px-3 py-2"
>
{listingPopup.listing.asking_price != null && (
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
{formatListingPrice(listingPopup.listing.asking_price)}
{listingPopup.listing.price_qualifier ? (
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
{listingPopup.listing.price_qualifier}
</span>
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing)}
</div>
)}
{listingPopup.listing.address && (
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
{listingPopup.listing.address}
</div>
)}
{listingPopup.listing.postcode && (
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
{listingPopup.listing.postcode}
</div>
)}
{listingPopup.listing.floor_area_sqm != null && (
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
{Math.round(listingPopup.listing.floor_area_sqm)} sqm
{listingPopup.listing.asking_price_per_sqm != null
? ` · £${Math.round(listingPopup.listing.asking_price_per_sqm).toLocaleString()}/sqm`
: ''}
</div>
)}
{listingPopup.listing.features.length > 0 && (
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
{listingPopup.listing.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="line-clamp-1">
{feature}
</li>
))}
</ul>
)}
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
Open listing
</div>
</a>
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCard
x={hoverPosition.x}

View file

@ -5,6 +5,7 @@ import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
@ -407,6 +408,10 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const { listings: actualListings } = useActualListings(
mapData.bounds,
mapData.currentView?.zoom ?? 0
);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -728,6 +733,7 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
@ -791,6 +797,7 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}

View file

@ -110,7 +110,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">

View file

@ -110,7 +110,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}

View file

@ -1,7 +1,14 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { useTutorial } from '../../../hooks/useTutorial';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
@ -45,6 +52,7 @@ interface DesktopMapPageProps {
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
@ -90,6 +98,7 @@ export function DesktopMapPage({
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
travelTimeEntries,
densityLabel,
totalCount,
@ -180,6 +189,7 @@ export function DesktopMapPage({
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}

View file

@ -1,6 +1,13 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { SearchedLocation } from '../LocationSearch';
@ -36,6 +43,7 @@ interface MobileMapPageProps {
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
onBottomSheetCoveredHeightChange: (height: number) => void;
@ -78,6 +86,7 @@ export function MobileMapPage({
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
travelTimeEntries,
bottomScreenInset,
onBottomSheetCoveredHeightChange,
@ -131,6 +140,7 @@ export function MobileMapPage({
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}