Frontend fixes
This commit is contained in:
parent
135f4be401
commit
245c16a212
7 changed files with 169 additions and 89 deletions
|
|
@ -1,6 +1,16 @@
|
|||
import { memo, useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
function SparklesIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
|
||||
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
|
||||
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface AiFilterInputProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -21,28 +31,35 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
|||
[query, loading, onSubmit]
|
||||
);
|
||||
|
||||
const hasContent = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal property and area..."
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Set filters with AI'
|
||||
)}
|
||||
</button>
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<SparklesIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-teal-500 dark:text-teal-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal area..."
|
||||
className="w-full pl-7 pr-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{(hasContent || loading) && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !hasContent}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export default function AreaPane({
|
|||
in this {isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
</p>
|
||||
{!isPostcode && stats && (
|
||||
{stats && stats.count > 0 && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
|
|
@ -135,6 +135,20 @@ export default function AreaPane({
|
|||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
<HistogramLegend />
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
|
|
@ -158,25 +172,10 @@ export default function AreaPane({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{group.name === 'Property' &&
|
||||
stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export default function FeatureBrowser({
|
|||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
|
|
@ -131,7 +131,7 @@ export default function FeatureBrowser({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
|
|||
|
|
@ -205,20 +205,29 @@ export default memo(function Filters({
|
|||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const [collapsedGroups, toggleGroup, expandGroup] = useCollapsibleGroups();
|
||||
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
const feature = features.find((f) => f.name === name);
|
||||
if (feature?.group) expandGroup(feature.group);
|
||||
onAddFilter(name);
|
||||
// Double rAF: first lets React commit the DOM update, second lets layout settle
|
||||
requestAnimationFrame(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[onAddFilter]
|
||||
[onAddFilter, features, expandGroup]
|
||||
);
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
|
|
@ -239,39 +248,6 @@ export default memo(function Filters({
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 border-b border-warm-200 dark:border-navy-700">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} />
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<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="flex items-center gap-2">
|
||||
|
|
@ -286,14 +262,43 @@ export default memo(function Filters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:flex-1 md:overflow-y-auto">
|
||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} />
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
{travelTimeEntries.length > 0 && (
|
||||
<div>
|
||||
<CollapsibleGroupHeader
|
||||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{travelTimeEntries.length}
|
||||
|
|
@ -336,7 +341,7 @@ export default memo(function Filters({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
|
|
@ -351,6 +356,7 @@ export default memo(function Filters({
|
|||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -407,6 +413,7 @@ export default memo(function Filters({
|
|||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export default function POIPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2 space-y-2">
|
||||
<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">
|
||||
POIs
|
||||
|
|
@ -137,14 +137,16 @@ export default function POIPane({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
</div>
|
||||
{filteredGroups.map((group) => {
|
||||
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
|
||||
const allInGroupSelected = groupSelected === group.categories.length;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue