improve AI
This commit is contained in:
parent
daf830c5ed
commit
b3a7ab40c8
7 changed files with 118 additions and 17 deletions
|
|
@ -1,15 +1,27 @@
|
||||||
import { memo, useState, useCallback } from 'react';
|
import { memo, useState, useCallback } from 'react';
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
||||||
|
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||||
|
|
||||||
interface AiFilterInputProps {
|
interface AiFilterInputProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
errorType: AiFilterErrorType | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
onSubmit: (query: string) => void;
|
onSubmit: (query: string) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
onLoginRequired: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: AiFilterInputProps) {
|
export default memo(function AiFilterInput({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
errorType,
|
||||||
|
notes,
|
||||||
|
onSubmit,
|
||||||
|
isLoggedIn,
|
||||||
|
onLoginRequired,
|
||||||
|
}: AiFilterInputProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
|
|
@ -17,9 +29,13 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed || loading) return;
|
if (!trimmed || loading) return;
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
onLoginRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSubmit(trimmed);
|
onSubmit(trimmed);
|
||||||
},
|
},
|
||||||
[query, loading, onSubmit]
|
[query, loading, isLoggedIn, onLoginRequired, onSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasContent = query.trim().length > 0;
|
const hasContent = query.trim().length > 0;
|
||||||
|
|
@ -52,7 +68,17 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
{error && (
|
{error && errorType === 'verification' && (
|
||||||
|
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && errorType === 'limit' && (
|
||||||
|
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && errorType === 'error' && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||||
import { FeatureActions } from '../ui/FeatureIcons';
|
import { FeatureActions } from '../ui/FeatureIcons';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||||
import AiFilterInput from './AiFilterInput';
|
import AiFilterInput from './AiFilterInput';
|
||||||
|
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||||
import FeatureBrowser from './FeatureBrowser';
|
import FeatureBrowser from './FeatureBrowser';
|
||||||
import { TravelTimeCard } from './TravelTimeCard';
|
import { TravelTimeCard } from './TravelTimeCard';
|
||||||
import {
|
import {
|
||||||
|
|
@ -89,8 +90,11 @@ interface FiltersProps {
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
aiFilterLoading: boolean;
|
aiFilterLoading: boolean;
|
||||||
aiFilterError: string | null;
|
aiFilterError: string | null;
|
||||||
|
aiFilterErrorType: AiFilterErrorType | null;
|
||||||
aiFilterNotes: string | null;
|
aiFilterNotes: string | null;
|
||||||
onAiFilterSubmit: (query: string) => void;
|
onAiFilterSubmit: (query: string) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
onLoginRequired: () => void;
|
||||||
isLicensed: boolean;
|
isLicensed: boolean;
|
||||||
onUpgradeClick?: () => void;
|
onUpgradeClick?: () => void;
|
||||||
onResetTutorial?: () => void;
|
onResetTutorial?: () => void;
|
||||||
|
|
@ -121,8 +125,11 @@ export default memo(function Filters({
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
aiFilterLoading,
|
aiFilterLoading,
|
||||||
aiFilterError,
|
aiFilterError,
|
||||||
|
aiFilterErrorType,
|
||||||
aiFilterNotes,
|
aiFilterNotes,
|
||||||
onAiFilterSubmit,
|
onAiFilterSubmit,
|
||||||
|
isLoggedIn,
|
||||||
|
onLoginRequired,
|
||||||
isLicensed,
|
isLicensed,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
onResetTutorial,
|
onResetTutorial,
|
||||||
|
|
@ -278,7 +285,7 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} 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} />
|
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
|
||||||
<div className="px-3 pb-2 space-y-2">
|
<div className="px-3 pb-2 space-y-2">
|
||||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||||
|
|
|
||||||
|
|
@ -143,16 +143,29 @@ export default function MapPage({
|
||||||
});
|
});
|
||||||
|
|
||||||
const aiFilters = useAiFilters();
|
const aiFilters = useAiFilters();
|
||||||
|
|
||||||
|
const travelTime = useTravelTime(initialTravelTime);
|
||||||
|
|
||||||
const handleAiFilterSubmit = useCallback(
|
const handleAiFilterSubmit = useCallback(
|
||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
const result = await aiFilters.fetchAiFilters(query);
|
const result = await aiFilters.fetchAiFilters(query);
|
||||||
if (result) handleSetFilters(result.filters);
|
if (!result) return;
|
||||||
|
handleSetFilters(result.filters);
|
||||||
|
// Apply travel time filters from AI
|
||||||
|
if (result.travelTimeFilters.length > 0) {
|
||||||
|
const newEntries = result.travelTimeFilters.map((tt) => ({
|
||||||
|
mode: tt.mode,
|
||||||
|
slug: tt.slug,
|
||||||
|
label: tt.label,
|
||||||
|
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
|
||||||
|
useBest: false,
|
||||||
|
}));
|
||||||
|
travelTime.handleSetEntries(newEntries);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[aiFilters.fetchAiFilters, handleSetFilters]
|
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const travelTime = useTravelTime(initialTravelTime);
|
|
||||||
|
|
||||||
const handleTravelTimeSetDestination = useCallback(
|
const handleTravelTimeSetDestination = useCallback(
|
||||||
(index: number, slug: string, label: string) => {
|
(index: number, slug: string, label: string) => {
|
||||||
travelTime.handleSetDestination(index, slug, label);
|
travelTime.handleSetDestination(index, slug, label);
|
||||||
|
|
@ -499,8 +512,11 @@ export default function MapPage({
|
||||||
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
||||||
aiFilterLoading={aiFilters.loading}
|
aiFilterLoading={aiFilters.loading}
|
||||||
aiFilterError={aiFilters.error}
|
aiFilterError={aiFilters.error}
|
||||||
|
aiFilterErrorType={aiFilters.errorType}
|
||||||
aiFilterNotes={aiFilters.notes}
|
aiFilterNotes={aiFilters.notes}
|
||||||
onAiFilterSubmit={handleAiFilterSubmit}
|
onAiFilterSubmit={handleAiFilterSubmit}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||||
isLicensed={user?.subscription === 'licensed'}
|
isLicensed={user?.subscription === 'licensed'}
|
||||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||||
onResetTutorial={tutorial.resetTutorial}
|
onResetTutorial={tutorial.resetTutorial}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ const H = 1.15; // digit slot height in em
|
||||||
|
|
||||||
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
|
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
|
||||||
const idx = DIGITS.indexOf(char);
|
const idx = DIGITS.indexOf(char);
|
||||||
if (idx === -1) return <span>{char}</span>;
|
if (idx === -1)
|
||||||
|
return (
|
||||||
|
<span className="inline-block" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const offset = active ? -idx * H : 0;
|
const offset = active ? -idx * H : 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import type { FeatureFilters } from '../types';
|
import type { FeatureFilters } from '../types';
|
||||||
|
import type { TransportMode } from './useTravelTime';
|
||||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
export interface AiTravelTimeFilter {
|
||||||
|
mode: TransportMode;
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AiFiltersResult {
|
interface AiFiltersResult {
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
|
travelTimeFilters: AiTravelTimeFilter[];
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
|
||||||
|
|
||||||
interface UseAiFiltersResult {
|
interface UseAiFiltersResult {
|
||||||
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
|
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
errorType: AiFilterErrorType | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAiFilters(): UseAiFiltersResult {
|
export function useAiFilters(): UseAiFiltersResult {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
|
||||||
const [notes, setNotes] = useState<string | null>(null);
|
const [notes, setNotes] = useState<string | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
|
@ -27,6 +41,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setErrorType(null);
|
||||||
setNotes(null);
|
setNotes(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -43,12 +58,36 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(text || `HTTP ${response.status}`);
|
if (response.status === 401) {
|
||||||
|
setErrorType('auth');
|
||||||
|
setError(text || 'Login required');
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
setErrorType('verification');
|
||||||
|
setError(text || 'Email verification required');
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
setErrorType('limit');
|
||||||
|
setError(text || 'Weekly usage limit reached');
|
||||||
|
} else {
|
||||||
|
setErrorType('error');
|
||||||
|
setError(text || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
|
||||||
|
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
|
||||||
|
mode: tt.mode as TransportMode,
|
||||||
|
slug: tt.slug,
|
||||||
|
label: tt.label,
|
||||||
|
min: tt.min,
|
||||||
|
max: tt.max,
|
||||||
|
})
|
||||||
|
);
|
||||||
const result: AiFiltersResult = {
|
const result: AiFiltersResult = {
|
||||||
filters: json.filters as FeatureFilters,
|
filters: json.filters as FeatureFilters,
|
||||||
|
travelTimeFilters,
|
||||||
notes: json.notes || '',
|
notes: json.notes || '',
|
||||||
};
|
};
|
||||||
setNotes(result.notes || null);
|
setNotes(result.notes || null);
|
||||||
|
|
@ -58,11 +97,12 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
if (controller.signal.aborted) return null;
|
if (controller.signal.aborted) return null;
|
||||||
logNonAbortError('ai-filters', err);
|
logNonAbortError('ai-filters', err);
|
||||||
const message = err instanceof Error ? err.message : 'Failed to generate filters';
|
const message = err instanceof Error ? err.message : 'Failed to generate filters';
|
||||||
|
setErrorType('error');
|
||||||
setError(message);
|
setError(message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { fetchAiFilters, loading, error, notes };
|
return { fetchAiFilters, loading, error, errorType, notes };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,9 @@ export function useMapData({
|
||||||
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||||
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
|
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
|
||||||
|
|
||||||
// Compute p5/p95 from visible data for the viewed feature
|
// Compute p5/p95 from committed data for the viewed feature.
|
||||||
|
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||||
|
// scale stays stable while dragging a filter slider.
|
||||||
const dataRange = useMemo((): [number, number] | null => {
|
const dataRange = useMemo((): [number, number] | null => {
|
||||||
if (!viewFeature) return null;
|
if (!viewFeature) return null;
|
||||||
|
|
||||||
|
|
@ -260,8 +262,8 @@ export function useMapData({
|
||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
|
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
if (effectivePostcodeData.length === 0) return null;
|
if (postcodeData.length === 0) return null;
|
||||||
for (const feat of effectivePostcodeData) {
|
for (const feat of postcodeData) {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||||
|
|
@ -271,8 +273,8 @@ export function useMapData({
|
||||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (data.length === 0) return null;
|
if (rawData.length === 0) return null;
|
||||||
for (const item of data) {
|
for (const item of rawData) {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
const { lat, lon } = item;
|
const { lat, lon } = item;
|
||||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||||
|
|
@ -289,7 +291,7 @@ export function useMapData({
|
||||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||||
];
|
];
|
||||||
}, [viewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]);
|
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||||||
|
|
||||||
// Color range for the legend and hex coloring
|
// Color range for the legend and hex coloring
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
const colorRange = useMemo((): [number, number] | null => {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||||
|
setEntries(newEntries);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** Entries that have a destination selected (slug is set) */
|
/** Entries that have a destination selected (slug is set) */
|
||||||
const activeEntries = useMemo(
|
const activeEntries = useMemo(
|
||||||
() => entries.filter((e) => e.slug !== ''),
|
() => entries.filter((e) => e.slug !== ''),
|
||||||
|
|
@ -95,6 +99,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
handleAddEntry,
|
handleAddEntry,
|
||||||
handleRemoveEntry,
|
handleRemoveEntry,
|
||||||
handleSetDestination,
|
handleSetDestination,
|
||||||
|
handleSetEntries,
|
||||||
handleTimeRangeChange,
|
handleTimeRangeChange,
|
||||||
handleToggleBest,
|
handleToggleBest,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue