England only
This commit is contained in:
parent
4d08f5d08d
commit
02712f41e8
8 changed files with 294 additions and 60 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue