good stuff

This commit is contained in:
Andras Schmelczer 2026-03-15 21:10:54 +00:00
parent ea8389ef40
commit f4de0eeb9f
39 changed files with 5165 additions and 348 deletions

View file

@ -61,6 +61,18 @@ export default memo(function AiFilterInput({
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const loadingMessage = useLoadingMessage(loading);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!expanded || loading) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [expanded, loading]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
@ -94,7 +106,7 @@ export default memo(function AiFilterInput({
if (!expanded) {
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
@ -110,7 +122,7 @@ export default memo(function AiFilterInput({
}
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>

View file

@ -43,7 +43,7 @@ export default function ExternalSearchLinks({
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{urls.rightmove ? (
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
@ -59,6 +59,11 @@ export default function ExternalSearchLinks({
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
{urls.openrent && (
<a href={urls.openrent} target="_blank" rel="noopener noreferrer" className={linkClass}>
OpenRent
</a>
)}
</div>
</div>
);

View file

@ -178,11 +178,19 @@ export default memo(function Filters({
[features, enabledFeatures]
);
const parkedFiltersRef = useRef<FeatureFilters>({});
const handleListingSelect = useCallback(
(type: ListingType) => {
// Track what will be active after swaps (to avoid conflicts with restoration)
const activeAfterSwaps = new Set<string>();
for (const name of Object.keys(filters)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
if (isAllowed(name, type)) {
activeAfterSwaps.add(name);
continue;
}
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
@ -191,15 +199,42 @@ export default memo(function Filters({
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
activeAfterSwaps.add(counterpart);
swapped = true;
break;
}
}
if (!swapped) {
parkedFiltersRef.current[name] = filters[name];
onRemoveFilter(name);
}
}
// Restore parked filters that are now allowed in the new mode
const restored: string[] = [];
for (const [name, value] of Object.entries(parkedFiltersRef.current)) {
if (isAllowed(name, type) && !activeAfterSwaps.has(name)) {
onFilterChange(name, value);
activeAfterSwaps.add(name);
restored.push(name);
} else if (!isAllowed(name, type)) {
// Try restoring as linked counterpart
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type) && !activeAfterSwaps.has(counterpart)) {
onFilterChange(counterpart, value);
activeAfterSwaps.add(counterpart);
restored.push(name);
break;
}
}
}
}
for (const name of restored) {
delete parkedFiltersRef.current[name];
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -232,7 +267,7 @@ export default memo(function Filters({
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
expandGroup('Travel Time');
expandGroup('Transport');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
@ -253,6 +288,16 @@ export default memo(function Filters({
[enabledFeatureList]
);
// Ensure "Transport" group exists in active filters when travel time entries are present
const mergedGroups = useMemo(() => {
if (travelTimeEntries.length === 0) return enabledGroups;
if (enabledGroups.some((g) => g.name === 'Transport')) return enabledGroups;
const groups = [...enabledGroups];
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
return groups;
}, [enabledGroups, travelTimeEntries.length]);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
for (const f of features) {
@ -396,13 +441,13 @@ export default memo(function Filters({
<div className="flex items-center justify-between">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
@ -460,7 +505,6 @@ export default memo(function Filters({
<div className="flex items-center justify-between gap-1">
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
className="min-w-0 shrink"
/>
@ -468,6 +512,7 @@ export default memo(function Filters({
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>