England only

This commit is contained in:
Andras Schmelczer 2026-03-15 14:03:38 +00:00
parent 4d08f5d08d
commit 02712f41e8
8 changed files with 294 additions and 60 deletions

View file

@ -1,8 +1,42 @@
import { memo, useState, useCallback } from 'react';
import { memo, useState, useCallback, useEffect, useRef } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { SparklesIcon } from '../ui/icons/SparklesIcon';
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
const EXAMPLE_QUERIES = [
'Safe area near good schools',
'30 min commute to Kings Cross, under 500k',
'Quiet village, 3 bed, fast broadband',
];
const LOADING_MESSAGES = [
'Analysing your query...',
'Searching for destinations...',
'Generating filters...',
];
/** Cycle through loading messages to show progress. */
function useLoadingMessage(loading: boolean): string {
const [index, setIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!loading) {
setIndex(0);
return;
}
// Advance message every 1.5s
timerRef.current = setTimeout(() => setIndex(1), 1500);
const t2 = setTimeout(() => setIndex(2), 3500);
return () => {
clearTimeout(timerRef.current);
clearTimeout(t2);
};
}, [loading]);
return LOADING_MESSAGES[index];
}
interface AiFilterInputProps {
loading: boolean;
error: string | null;
@ -23,6 +57,8 @@ export default memo(function AiFilterInput({
onLoginRequired,
}: AiFilterInputProps) {
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState(false);
const loadingMessage = useLoadingMessage(loading);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
@ -38,36 +74,90 @@ export default memo(function AiFilterInput({
[query, loading, isLoggedIn, onLoginRequired, onSubmit]
);
const handleExampleClick = useCallback(
(example: string) => {
if (loading) return;
setQuery(example);
if (!isLoggedIn) {
onLoginRequired();
return;
}
onSubmit(example);
},
[loading, isLoggedIn, onLoginRequired, onSubmit]
);
const hasContent = query.trim().length > 0;
const showExamples = expanded && !hasContent && !loading && !error && !notes;
if (!expanded) {
return (
<div className="px-3 py-2" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
>
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
Describe your ideal area with AI
</span>
</button>
</div>
);
}
return (
<div 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>
<span className="text-xs text-warm-400 dark:text-warm-500"> describe what you're looking for</span>
</div>
<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>
)}
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. quiet area, under 400k, near good schools..."
className="flex-1 px-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}
autoFocus
/>
<button
type="submit"
disabled={loading || !hasContent}
className="shrink-0 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 gap-1.5"
>
{loading ? (
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
) : (
<>
<SparklesIcon className="w-3.5 h-3.5" />
<span>Search</span>
</>
)}
</button>
</form>
{loading && (
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
{loadingMessage}
</p>
)}
{showExamples && (
<div className="mt-1.5 flex flex-wrap gap-1">
{EXAMPLE_QUERIES.map((example) => (
<button
key={example}
type="button"
onClick={() => handleExampleClick(example)}
className="text-xs px-2 py-0.5 rounded-full border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:border-teal-400 hover:text-teal-600 dark:hover:text-teal-400 cursor-pointer"
>
{example}
</button>
))}
</div>
)}
{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.
@ -83,7 +173,7 @@ export default memo(function AiFilterInput({
{error}
</p>
)}
{notes && !error && (
{notes && !error && !loading && (
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
{notes}
</p>