diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 937f6de..e7e5c55 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -1,15 +1,27 @@ import { memo, useState, useCallback } from 'react'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SparklesIcon } from '../ui/icons/SparklesIcon'; +import type { AiFilterErrorType } from '../../hooks/useAiFilters'; interface AiFilterInputProps { loading: boolean; error: string | null; + errorType: AiFilterErrorType | null; notes: string | null; 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 handleSubmit = useCallback( @@ -17,9 +29,13 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: e.preventDefault(); const trimmed = query.trim(); if (!trimmed || loading) return; + if (!isLoggedIn) { + onLoginRequired(); + return; + } onSubmit(trimmed); }, - [query, loading, onSubmit] + [query, loading, isLoggedIn, onLoginRequired, onSubmit] ); const hasContent = query.trim().length > 0; @@ -52,7 +68,17 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: )} - {error && ( + {error && errorType === 'verification' && ( +

+ Please verify your email address to use AI-powered search. Check your inbox for a verification link. +

+ )} + {error && errorType === 'limit' && ( +

+ You've reached the weekly AI usage limit. It will reset automatically next week. +

+ )} + {error && errorType === 'error' && (

{error}

diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 5b00bee..280148b 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -15,6 +15,7 @@ import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; import AiFilterInput from './AiFilterInput'; +import type { AiFilterErrorType } from '../../hooks/useAiFilters'; import FeatureBrowser from './FeatureBrowser'; import { TravelTimeCard } from './TravelTimeCard'; import { @@ -89,8 +90,11 @@ interface FiltersProps { onTravelTimeToggleBest: (index: number) => void; aiFilterLoading: boolean; aiFilterError: string | null; + aiFilterErrorType: AiFilterErrorType | null; aiFilterNotes: string | null; onAiFilterSubmit: (query: string) => void; + isLoggedIn: boolean; + onLoginRequired: () => void; isLicensed: boolean; onUpgradeClick?: () => void; onResetTutorial?: () => void; @@ -121,8 +125,11 @@ export default memo(function Filters({ onTravelTimeToggleBest, aiFilterLoading, aiFilterError, + aiFilterErrorType, aiFilterNotes, onAiFilterSubmit, + isLoggedIn, + onLoginRequired, isLicensed, onUpgradeClick, onResetTutorial, @@ -278,7 +285,7 @@ export default memo(function Filters({
- +
{(['historical', 'buy', 'rent'] as const).map((type) => { diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 748e241..c232472 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -143,16 +143,29 @@ export default function MapPage({ }); const aiFilters = useAiFilters(); + + const travelTime = useTravelTime(initialTravelTime); + const handleAiFilterSubmit = useCallback( async (query: string) => { 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( (index: number, slug: string, label: string) => { travelTime.handleSetDestination(index, slug, label); @@ -499,8 +512,11 @@ export default function MapPage({ onTravelTimeToggleBest={travelTime.handleToggleBest} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} + aiFilterErrorType={aiFilters.errorType} aiFilterNotes={aiFilters.notes} onAiFilterSubmit={handleAiFilterSubmit} + isLoggedIn={!!user} + onLoginRequired={onRegisterClick ?? (() => {})} isLicensed={user?.subscription === 'licensed'} onUpgradeClick={() => onNavigateTo('pricing')} onResetTutorial={tutorial.resetTutorial} diff --git a/frontend/src/components/ui/TickerValue.tsx b/frontend/src/components/ui/TickerValue.tsx index e144499..0cea213 100644 --- a/frontend/src/components/ui/TickerValue.tsx +++ b/frontend/src/components/ui/TickerValue.tsx @@ -3,7 +3,12 @@ const H = 1.15; // digit slot height in em function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) { const idx = DIGITS.indexOf(char); - if (idx === -1) return {char}; + if (idx === -1) + return ( + + {char} + + ); const offset = active ? -idx * H : 0; diff --git a/frontend/src/hooks/useAiFilters.ts b/frontend/src/hooks/useAiFilters.ts index ab26d2e..d4c775e 100644 --- a/frontend/src/hooks/useAiFilters.ts +++ b/frontend/src/hooks/useAiFilters.ts @@ -1,22 +1,36 @@ import { useState, useCallback, useRef } from 'react'; import type { FeatureFilters } from '../types'; +import type { TransportMode } from './useTravelTime'; import { apiUrl, authHeaders, logNonAbortError } from '../lib/api'; +export interface AiTravelTimeFilter { + mode: TransportMode; + slug: string; + label: string; + min?: number; + max?: number; +} + interface AiFiltersResult { filters: FeatureFilters; + travelTimeFilters: AiTravelTimeFilter[]; notes: string; } +export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error'; + interface UseAiFiltersResult { fetchAiFilters: (query: string) => Promise; loading: boolean; error: string | null; + errorType: AiFilterErrorType | null; notes: string | null; } export function useAiFilters(): UseAiFiltersResult { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [errorType, setErrorType] = useState(null); const [notes, setNotes] = useState(null); const abortRef = useRef(null); @@ -27,6 +41,7 @@ export function useAiFilters(): UseAiFiltersResult { setLoading(true); setError(null); + setErrorType(null); setNotes(null); try { @@ -43,12 +58,36 @@ export function useAiFilters(): UseAiFiltersResult { if (!response.ok) { 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 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 = { filters: json.filters as FeatureFilters, + travelTimeFilters, notes: json.notes || '', }; setNotes(result.notes || null); @@ -58,11 +97,12 @@ export function useAiFilters(): UseAiFiltersResult { if (controller.signal.aborted) return null; logNonAbortError('ai-filters', err); const message = err instanceof Error ? err.message : 'Failed to generate filters'; + setErrorType('error'); setError(message); setLoading(false); return null; } }, []); - return { fetchAiFilters, loading, error, notes }; + return { fetchAiFilters, loading, error, errorType, notes }; } diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 17e7fa2..d6eafbc 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -246,7 +246,9 @@ export function useMapData({ const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData; 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 => { if (!viewFeature) return null; @@ -260,8 +262,8 @@ export function useMapData({ const vals: number[] = []; if (usePostcodeView) { - if (effectivePostcodeData.length === 0) return null; - for (const feat of effectivePostcodeData) { + if (postcodeData.length === 0) return null; + for (const feat of postcodeData) { if (bounds) { const [lng, lat] = feat.properties.centroid as [number, number]; 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); } } else { - if (data.length === 0) return null; - for (const item of data) { + if (rawData.length === 0) return null; + for (const item of rawData) { if (bounds) { const { lat, lon } = item; 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_HIGH_PERCENTILE), ]; - }, [viewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]); + }, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]); // Color range for the legend and hex coloring const colorRange = useMemo((): [number, number] | null => { diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index e448942..4897bdb 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -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) */ const activeEntries = useMemo( () => entries.filter((e) => e.slug !== ''), @@ -95,6 +99,7 @@ export function useTravelTime(initial?: TravelTimeInitial) { handleAddEntry, handleRemoveEntry, handleSetDestination, + handleSetEntries, handleTimeRangeChange, handleToggleBest, };