Fix crime & add actual listings
This commit is contained in:
parent
017902b8e6
commit
ebe7bbb51d
34 changed files with 2014 additions and 172754 deletions
|
|
@ -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' : ''
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue