good stuff
This commit is contained in:
parent
ea8389ef40
commit
f4de0eeb9f
39 changed files with 5165 additions and 348 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue