More fixes
This commit is contained in:
parent
15fa09430b
commit
6b12e21d50
54 changed files with 1665 additions and 630 deletions
|
|
@ -395,6 +395,7 @@ export default function App() {
|
|||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
||||
deferTutorial={showLicenseSuccess}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
|
|
@ -172,6 +172,7 @@ function SavedSearchesTab({
|
|||
|
||||
const handleShare = useCallback(
|
||||
async (params: string, id: string) => {
|
||||
prewarmScreenshot(params);
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
|
|
@ -213,7 +214,7 @@ function SavedSearchesTab({
|
|||
{searches.map((search) => (
|
||||
<div
|
||||
key={search.id}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
|
||||
className="flex flex-col bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
{search.screenshotUrl ? (
|
||||
<img
|
||||
|
|
@ -227,7 +228,7 @@ function SavedSearchesTab({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
|
||||
{search.name}
|
||||
</h3>
|
||||
|
|
@ -238,14 +239,14 @@ function SavedSearchesTab({
|
|||
{summarizeParams(search.params)}
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 flex-1">
|
||||
<NotesInput
|
||||
value={search.notes}
|
||||
onSave={(notes) => onUpdateNotes(search.id, notes)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<button
|
||||
onClick={() => onOpen(search.params)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
|
|
@ -342,7 +343,7 @@ function SavedPropertiesTab({
|
|||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
||||
className="flex flex-col bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
||||
>
|
||||
<div className="mb-1">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
||||
|
|
@ -360,35 +361,37 @@ function SavedPropertiesTab({
|
|||
{formatRelativeTime(prop.created)}
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 flex-1">
|
||||
<NotesInput value={prop.notes} onSave={(notes) => onUpdateNotes(prop.id, notes)} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onOpen(prop.postcode)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
Open postcode
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(prop.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="mt-auto">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onOpen(prop.postcode)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
Open postcode
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(prop.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{prop.data.listingUrl && (
|
||||
<a
|
||||
href={prop.data.listingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View listing →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{prop.data.listingUrl && (
|
||||
<a
|
||||
href={prop.data.listingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View listing →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -94,10 +94,14 @@ export default function InvitePage({
|
|||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Signal screenshot readiness once loading completes
|
||||
// Signal screenshot readiness once loading completes and a frame has painted
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !loading) {
|
||||
window.__screenshot_ready = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.__screenshot_ready = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [screenshotMode, loading]);
|
||||
|
||||
|
|
@ -313,7 +317,7 @@ export default function InvitePage({
|
|||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={redeeming}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg shadow-lg shadow-teal-600/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{isAdminInvite
|
||||
|
|
|
|||
|
|
@ -10,23 +10,18 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { PlusIcon, InfoIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
||||
import {
|
||||
TRANSPORT_MODES,
|
||||
MODE_LABELS,
|
||||
MODE_DESCRIPTIONS,
|
||||
MODE_ICONS,
|
||||
type TransportMode,
|
||||
type TravelTimeEntry,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
bicycle: BicycleIcon,
|
||||
walking: WalkingIcon,
|
||||
transit: TransitIcon,
|
||||
};
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
allFeatures: FeatureMeta[];
|
||||
|
|
@ -58,6 +53,7 @@ export default function FeatureBrowser({
|
|||
}: FeatureBrowserProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
|
||||
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const availableTravelModes = useTravelModes();
|
||||
|
||||
|
|
@ -175,6 +171,12 @@ export default function FeatureBrowser({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<IconButton
|
||||
onClick={() => setTravelInfoMode(mode)}
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||
|
|
@ -241,6 +243,9 @@ export default function FeatureBrowser({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
{travelInfoMode && (
|
||||
<TravelTimeInfoPopup mode={travelInfoMode} onClose={() => setTravelInfoMode(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { LightbulbIcon } from '../ui/icons';
|
||||
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
|
||||
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
||||
import { formatFilterValue, parseInputValue, buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
|
|
@ -23,6 +23,73 @@ import {
|
|||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
function EditableLabel({
|
||||
value,
|
||||
formatted,
|
||||
onCommit,
|
||||
prefix,
|
||||
suffix,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
value: number;
|
||||
formatted: string;
|
||||
onCommit: (v: number) => void;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
setText(String(Math.round(value)));
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const parsed = parseInputValue(text, { prefix, suffix });
|
||||
if (parsed != null) onCommit(parsed);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="absolute -translate-x-1/2 w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`absolute -translate-x-1/2 cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
|
||||
style={style}
|
||||
onClick={startEdit}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderLabels({
|
||||
min,
|
||||
max,
|
||||
|
|
@ -31,6 +98,8 @@ function SliderLabels({
|
|||
isAtMin,
|
||||
isAtMax,
|
||||
raw,
|
||||
feature,
|
||||
onValueChange,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
|
|
@ -39,18 +108,47 @@ function SliderLabels({
|
|||
isAtMin?: boolean;
|
||||
isAtMax?: boolean;
|
||||
raw?: boolean;
|
||||
feature?: FeatureMeta;
|
||||
onValueChange?: (v: [number, number]) => void;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = ((value[0] - min) / range) * 100;
|
||||
const rightPct = ((value[1] - min) / range) * 100;
|
||||
const labels = displayValues || value;
|
||||
|
||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw);
|
||||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
onCommit={(v) => onValueChange([v, labels[1]])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${leftPct}%` }}
|
||||
/>
|
||||
<EditableLabel
|
||||
value={labels[1]}
|
||||
formatted={maxLabel}
|
||||
onCommit={(v) => onValueChange([labels[0], v])}
|
||||
prefix={feature.prefix}
|
||||
suffix={feature.suffix}
|
||||
style={{ left: `${rightPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
|
||||
{minLabel}
|
||||
</span>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
|
||||
{maxLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -246,6 +344,7 @@ export default memo(function Filters({
|
|||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [addFilterCollapsed, setAddFilterCollapsed] = useState(false);
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const pendingScrollRef = useRef<string | null>(null);
|
||||
|
|
@ -292,10 +391,10 @@ export default memo(function Filters({
|
|||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
||||
>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className={`shrink-0 md:shrink md:min-h-0 flex flex-col ${addFilterCollapsed ? '' : 'md:basis-[40%]'}`}>
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
Active Filters
|
||||
</span>
|
||||
{badgeCount > 0 && (
|
||||
|
|
@ -427,16 +526,18 @@ export default memo(function Filters({
|
|||
const scale = percentileScales.get(feature.name);
|
||||
const dataMin = hist?.min ?? feature.min!;
|
||||
const dataMax = hist?.max ?? feature.max!;
|
||||
const isAtMin = displayValue[0] <= dataMin;
|
||||
const isAtMax = displayValue[1] >= dataMax;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
isAtMin ? feature.min! : displayValue[0],
|
||||
isAtMax ? feature.max! : displayValue[1],
|
||||
clampMin ? feature.min! : displayValue[0],
|
||||
clampMax ? feature.max! : displayValue[1],
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -494,6 +595,8 @@ export default memo(function Filters({
|
|||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
feature={feature}
|
||||
onValueChange={(v) => onFilterChange(feature.name, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -503,26 +606,32 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 md:shrink md:min-h-0 hidden md:flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||
</div>
|
||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={onTogglePin}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
isLicensed={isLicensed}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
</div>
|
||||
<div className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}>
|
||||
<button
|
||||
onClick={() => setAddFilterCollapsed((v) => !v)}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">Add Filter</span>
|
||||
<ChevronIcon direction={addFilterCollapsed ? 'down' : 'up'} className="w-4 h-4 text-warm-400 dark:text-warm-500" />
|
||||
</button>
|
||||
{!addFilterCollapsed && (
|
||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={onTogglePin}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
isLicensed={isLicensed}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPhilosophy && (
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export default memo(function HoverCard({
|
|||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ export default function JourneyInstructions({
|
|||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
To {j.label || j.slug}
|
||||
</span>
|
||||
{displayLegs && displayLegs.length > 0 && (
|
||||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{totalMin} min
|
||||
</span>
|
||||
|
|
@ -269,6 +269,26 @@ export default function JourneyInstructions({
|
|||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
) : j.minutes != null ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
Walk · {j.minutes} min
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
No journey data available
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ interface MapPageProps {
|
|||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
deferTutorial?: boolean;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -99,6 +100,7 @@ export default function MapPage({
|
|||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
deferTutorial = false,
|
||||
}: MapPageProps) {
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
|
|
@ -153,6 +155,14 @@ export default function MapPage({
|
|||
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
// Derive current listing type from Listing status filter
|
||||
const listingVal = filters['Listing status'] as string[] | undefined;
|
||||
const listingType = listingVal?.includes('For sale')
|
||||
? 'buy'
|
||||
: listingVal?.includes('For rent')
|
||||
? 'rent'
|
||||
: 'historical';
|
||||
|
||||
// Build context from current filters for conversational refinement
|
||||
const context = {
|
||||
filters,
|
||||
|
|
@ -165,7 +175,11 @@ export default function MapPage({
|
|||
};
|
||||
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
|
||||
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
|
||||
const result = await aiFilters.fetchAiFilters(
|
||||
query,
|
||||
hasContext ? context : undefined,
|
||||
listingType
|
||||
);
|
||||
if (!result) return;
|
||||
handleSetFilters(result.filters);
|
||||
// Always sync travel time entries — clear stale ones when AI returns none
|
||||
|
|
@ -354,7 +368,7 @@ export default function MapPage({
|
|||
selection.areaStats?.central_postcode,
|
||||
]);
|
||||
|
||||
const tutorial = useTutorial(initialLoading, isMobile);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const handleExport = useCallback(() => {
|
||||
|
|
@ -418,7 +432,14 @@ export default function MapPage({
|
|||
? mapData.postcodeData.length > 0
|
||||
: mapData.data.length > 0;
|
||||
if (hasData) {
|
||||
window.__screenshot_ready = true;
|
||||
// Wait for deck.gl to actually paint: in interleaved MapboxOverlay mode,
|
||||
// hexagons render during MapLibre's rAF cycle. Double-rAF ensures at
|
||||
// least one full paint has completed before we signal readiness.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.__screenshot_ready = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function POIPane({
|
|||
const selectedCount = selectedCategories.size;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-white dark:bg-warm-900 shadow-lg overflow-hidden">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
|
|
|
|||
|
|
@ -4,24 +4,13 @@ import { IconButton } from '../ui/IconButton';
|
|||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { DestinationDropdown } from '../ui/DestinationDropdown';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { CarIcon } from '../ui/icons/CarIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { TransitIcon } from '../ui/icons/TransitIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
bicycle: BicycleIcon,
|
||||
walking: WalkingIcon,
|
||||
transit: TransitIcon,
|
||||
};
|
||||
import { MODE_LABELS, MODE_ICONS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
mode: TransportMode;
|
||||
|
|
@ -118,21 +107,7 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={() => setShowInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{mode === 'transit'
|
||||
? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
|
||||
: mode === 'car'
|
||||
? ' by car, based on typical road speeds and the road network.'
|
||||
: mode === 'bicycle'
|
||||
? ' by bicycle, using cycle-friendly routes.'
|
||||
: ' on foot, using pedestrian paths and pavements.'}{' '}
|
||||
Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
||||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function AuthModal({
|
|||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" onMouseDown={onClose} />
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { shortenUrl } from '../../lib/api';
|
||||
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
|
|
@ -96,6 +96,7 @@ export default function Header({
|
|||
doCopy(window.location.href);
|
||||
return;
|
||||
}
|
||||
prewarmScreenshot(params);
|
||||
setSharing(true);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
|
|
@ -243,6 +244,7 @@ export default function Header({
|
|||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onLogout={onLogout}
|
||||
onNavigate={onPageChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
|||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||
|
|
|
|||
27
frontend/src/components/ui/TravelTimeInfoPopup.tsx
Normal file
27
frontend/src/components/ui/TravelTimeInfoPopup.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import InfoPopup from './InfoPopup';
|
||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_INFO: Record<TransportMode, string> = {
|
||||
transit:
|
||||
' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
|
||||
car: ' by car, based on typical road speeds and the road network.',
|
||||
bicycle: ' by bicycle, using cycle-friendly routes.',
|
||||
walking: ' on foot, using pedestrian paths and pavements.',
|
||||
};
|
||||
|
||||
export function TravelTimeInfoPopup({
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
mode: TransportMode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={onClose}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{MODE_INFO[mode]} Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { Page } from './Header';
|
||||
import { PAGE_PATHS } from './Header';
|
||||
import { SunIcon } from './icons/SunIcon';
|
||||
import { MoonIcon } from './icons/MoonIcon';
|
||||
|
||||
|
|
@ -8,11 +10,13 @@ export default function UserMenu({
|
|||
theme,
|
||||
onToggleTheme,
|
||||
onLogout,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onLogout: () => void;
|
||||
onNavigate: (page: Page) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -72,8 +76,13 @@ export default function UserMenu({
|
|||
Theme: {theme === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<a
|
||||
href="/account"
|
||||
onClick={() => setOpen(false)}
|
||||
href={PAGE_PATHS.account}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
onNavigate('account');
|
||||
}}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Account
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface AiFiltersResult {
|
|||
notes: string;
|
||||
/** Human-readable summary of what was set */
|
||||
summary: string;
|
||||
/** The listing mode used (historical/buy/rent) */
|
||||
listingType: string;
|
||||
}
|
||||
|
||||
export type AiFilterErrorType = 'auth' | 'limit' | 'error';
|
||||
|
|
@ -28,7 +30,11 @@ export interface AiFiltersContext {
|
|||
}
|
||||
|
||||
interface UseAiFiltersResult {
|
||||
fetchAiFilters: (query: string, context?: AiFiltersContext) => Promise<AiFiltersResult | null>;
|
||||
fetchAiFilters: (
|
||||
query: string,
|
||||
context?: AiFiltersContext,
|
||||
listingType?: string
|
||||
) => Promise<AiFiltersResult | null>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
errorType: AiFilterErrorType | null;
|
||||
|
|
@ -41,6 +47,8 @@ function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFi
|
|||
const parts: string[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
// Skip Listing status — shown via the mode selector UI
|
||||
if (name === 'Listing status') continue;
|
||||
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
|
||||
parts.push(name);
|
||||
} else if (Array.isArray(value)) {
|
||||
|
|
@ -67,7 +75,11 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchAiFilters = useCallback(
|
||||
async (query: string, context?: AiFiltersContext): Promise<AiFiltersResult | null> => {
|
||||
async (
|
||||
query: string,
|
||||
context?: AiFiltersContext,
|
||||
listingType?: string
|
||||
): Promise<AiFiltersResult | null> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
|
@ -81,6 +93,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
try {
|
||||
const url = apiUrl('ai-filters');
|
||||
const bodyObj: Record<string, unknown> = { query };
|
||||
if (listingType) bodyObj.listing_type = listingType;
|
||||
if (context) {
|
||||
bodyObj.context = {
|
||||
filters: context.filters,
|
||||
|
|
@ -130,6 +143,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
travelTimeFilters,
|
||||
notes: json.notes || '',
|
||||
summary: summaryText,
|
||||
listingType: json.listing_type || 'historical',
|
||||
};
|
||||
setNotes(result.notes || null);
|
||||
setSummary(summaryText);
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function useDeckLayers({
|
|||
useEffect(() => {
|
||||
if (!hasSelection) return;
|
||||
setMarchTime(0);
|
||||
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
|
||||
const id = setInterval(() => setMarchTime((t) => (t + 0.3) % 10000), 50);
|
||||
return () => clearInterval(id);
|
||||
}, [hasSelection]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
|
||||
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
||||
|
|
@ -18,6 +20,13 @@ export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
|||
transit: 'Journey time by train, tube, and bus',
|
||||
};
|
||||
|
||||
export const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
bicycle: BicycleIcon,
|
||||
walking: WalkingIcon,
|
||||
transit: TransitIcon,
|
||||
};
|
||||
|
||||
export interface TravelTimeEntry {
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
|
|
|
|||
|
|
@ -59,13 +59,13 @@ const STEPS: Step[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function useTutorial(initialLoading: boolean, isMobile: boolean) {
|
||||
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
|
||||
const [run, setRun] = useState(() => {
|
||||
if (isMobile) return false;
|
||||
return !localStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
const shouldRun = run && !initialLoading && !isMobile;
|
||||
const shouldRun = run && !initialLoading && !isMobile && !blocked;
|
||||
|
||||
const handleCallback = useCallback((data: CallBackProps) => {
|
||||
const { status, action, type } = data;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ export async function fetchWithRetry<T>(
|
|||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
||||
export function prewarmScreenshot(params: string): void {
|
||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders())
|
||||
.catch(() => {}); // best-effort, don't care if it fails
|
||||
}
|
||||
|
||||
export async function shortenUrl(params: string): Promise<string> {
|
||||
const res = await fetch(apiUrl('shorten'), {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
||||
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
|
||||
// Fallback if clipboard permission denied
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 13, resolution: 9 },
|
||||
] as const;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
|
|
@ -183,8 +183,8 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
feature: 'Leashold/Freehold',
|
||||
components: ['Leashold/Freehold'],
|
||||
feature: 'Leasehold/Freehold',
|
||||
components: ['Leasehold/Freehold'],
|
||||
valueOrder: ['Freehold', 'Leasehold'],
|
||||
valueColors: ['#3b82f6', '#f59e0b'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -49,24 +49,57 @@ const RIGHTMOVE_PRICES = [
|
|||
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
|
||||
];
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
// Rightmove allowed monthly rent values (pcm)
|
||||
const RIGHTMOVE_RENTS = [
|
||||
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000,
|
||||
3500, 4000, 5000, 7500, 10000, 15000, 25000,
|
||||
];
|
||||
|
||||
/** Snap minPrice down and maxPrice up so Rightmove doesn't ignore them */
|
||||
function snapRightmovePrice(value: number, direction: 'floor' | 'ceil'): number {
|
||||
// OnTheMarket allowed buy prices
|
||||
const OTM_PRICES = [
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
|
||||
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000,
|
||||
300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
|
||||
700000, 750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000,
|
||||
7500000, 10000000, 15000000,
|
||||
];
|
||||
|
||||
// OnTheMarket allowed monthly rent values (pcm)
|
||||
const OTM_RENTS = [
|
||||
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
|
||||
1100, 1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000,
|
||||
25000,
|
||||
];
|
||||
|
||||
// Zoopla allowed buy prices
|
||||
const ZOOPLA_PRICES = [
|
||||
10000, 25000, 50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000, 250000, 275000,
|
||||
300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
|
||||
700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, 2500000, 3000000, 4000000,
|
||||
5000000, 7500000, 10000000, 15000000,
|
||||
];
|
||||
|
||||
// Zoopla allowed monthly rent values (pcm)
|
||||
const ZOOPLA_RENTS = [
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
|
||||
4000, 5000, 7500, 10000, 25000,
|
||||
];
|
||||
|
||||
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
|
||||
if (direction === 'floor') {
|
||||
// Largest supported value <= target
|
||||
for (let i = RIGHTMOVE_PRICES.length - 1; i >= 0; i--) {
|
||||
if (RIGHTMOVE_PRICES[i] <= value) return RIGHTMOVE_PRICES[i];
|
||||
for (let i = allowed.length - 1; i >= 0; i--) {
|
||||
if (allowed[i] <= value) return allowed[i];
|
||||
}
|
||||
return RIGHTMOVE_PRICES[0];
|
||||
return allowed[0];
|
||||
}
|
||||
// Smallest supported value >= target
|
||||
for (const p of RIGHTMOVE_PRICES) {
|
||||
for (const p of allowed) {
|
||||
if (p >= value) return p;
|
||||
}
|
||||
return RIGHTMOVE_PRICES[RIGHTMOVE_PRICES.length - 1];
|
||||
return allowed[allowed.length - 1];
|
||||
}
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
|
||||
interface SearchUrlOptions {
|
||||
|
|
@ -90,7 +123,17 @@ export function buildPropertySearchUrls({
|
|||
|
||||
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const priceFilter = filters['Last known price'];
|
||||
const listingStatus = filters['Listing status'];
|
||||
const isRent =
|
||||
Array.isArray(listingStatus) &&
|
||||
typeof listingStatus[0] === 'string' &&
|
||||
(listingStatus as string[]).includes('For rent');
|
||||
|
||||
// Check price filters in priority order: asking price (current listings) > estimated > last known
|
||||
// For rent mode, check asking rent first
|
||||
const priceFilter = isRent
|
||||
? filters['Asking rent (monthly)']
|
||||
: (filters['Asking price'] ?? filters['Estimated current price'] ?? filters['Last known price']);
|
||||
const minPrice =
|
||||
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice =
|
||||
|
|
@ -131,15 +174,16 @@ export function buildPropertySearchUrls({
|
|||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
const rmPrices = isRent ? RIGHTMOVE_RENTS : RIGHTMOVE_PRICES;
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode);
|
||||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined)
|
||||
rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
|
||||
rmParams.set('minPrice', String(snapToAllowed(minPrice, rmPrices, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
|
||||
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, rmPrices, 'ceil')));
|
||||
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
|
||||
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
|
||||
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
|
||||
|
|
@ -155,20 +199,24 @@ export function buildPropertySearchUrls({
|
|||
];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
if (selectedTenures.length > 0) {
|
||||
if (!isRent && selectedTenures.length > 0) {
|
||||
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
|
||||
rmParams.set('tenureTypes', rmTenures.join(','));
|
||||
}
|
||||
rmParams.set('_includeSSTC', 'on');
|
||||
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
if (!isRent) rmParams.set('_includeSSTC', 'on');
|
||||
const rmPath = isRent ? 'property-to-rent' : 'property-for-sale';
|
||||
rightmove = `https://www.rightmove.co.uk/${rmPath}/find.html?${rmParams.toString()}`;
|
||||
}
|
||||
|
||||
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
||||
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
||||
const otmPrices = isRent ? OTM_RENTS : OTM_PRICES;
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||
if (minPrice !== undefined)
|
||||
otmParams.set('min-price', String(snapToAllowed(minPrice, otmPrices, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
otmParams.set('max-price', String(snapToAllowed(maxPrice, otmPrices, 'ceil')));
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
|
|
@ -178,15 +226,20 @@ export function buildPropertySearchUrls({
|
|||
}
|
||||
}
|
||||
otmParams.set('view', 'map-list');
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
const otmPath = isRent ? 'to-rent' : 'for-sale';
|
||||
const onthemarket = `https://www.onthemarket.com/${otmPath}/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
|
||||
// Zoopla
|
||||
const zPrices = isRent ? ZOOPLA_RENTS : ZOOPLA_PRICES;
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', postcode);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
const zSearchSource = isRent ? 'to-rent' : 'for-sale';
|
||||
zParams.set('search_source', zSearchSource);
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
|
||||
if (minPrice !== undefined)
|
||||
zParams.set('price_min', String(snapToAllowed(minPrice, zPrices, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
zParams.set('price_max', String(snapToAllowed(maxPrice, zPrices, 'ceil')));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
|
||||
|
|
@ -195,14 +248,9 @@ export function buildPropertySearchUrls({
|
|||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
const zoopla = `https://www.zoopla.co.uk/${zSearchSource}/property/?${zParams.toString()}`;
|
||||
|
||||
// OpenRent — rent mode only
|
||||
const listingStatus = filters['Listing status'];
|
||||
const isRent =
|
||||
Array.isArray(listingStatus) &&
|
||||
typeof listingStatus[0] === 'string' &&
|
||||
(listingStatus as string[]).includes('For rent');
|
||||
let openrent: string | null = null;
|
||||
if (isRent) {
|
||||
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
|
||||
|
|
|
|||
|
|
@ -23,6 +23,26 @@ export function formatFilterValue(value: number, raw?: boolean): string {
|
|||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
|
||||
export function parseInputValue(
|
||||
text: string,
|
||||
opts?: { prefix?: string; suffix?: string; step?: number }
|
||||
): number | null {
|
||||
let s = text.trim();
|
||||
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
|
||||
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
|
||||
s = s.trim().replace(/,/g, '');
|
||||
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
|
||||
if (!m) return null;
|
||||
let val = parseFloat(m[1]);
|
||||
if (isNaN(val)) return null;
|
||||
const unit = m[2].toLowerCase();
|
||||
if (unit === 'k') val *= 1_000;
|
||||
else if (unit === 'm') val *= 1_000_000;
|
||||
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
|
||||
return val;
|
||||
}
|
||||
|
||||
export function formatDuration(d: string): string {
|
||||
if (d === 'F') return 'Freehold';
|
||||
if (d === 'L') return 'Leasehold';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue