Public Access
1
0
Files
leads4less/src/components/SearchSetup.tsx
T

224 lines
9.1 KiB
TypeScript

import React, { useCallback, useState } from 'react';
import { AlertCircle, LocateFixed, Loader2, MapPin, Play } from 'lucide-react';
import { runSearch } from '../lib/database';
import type { AppUser } from '../../shared/types';
import { BasicResearchMap } from './BasicResearchMap';
interface SearchSetupProps {
user: AppUser;
onSelectCreatedJob: (jobId: string) => void;
topContent?: React.ReactNode;
}
export function SearchSetup({
user: _user,
onSelectCreatedJob,
topContent,
}: SearchSetupProps) {
const [name, setName] = useState('');
const [radius, setRadius] = useState(5);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
const [pin, setPin] = useState<google.maps.LatLngLiteral | null>(null);
const [locationError, setLocationError] = useState<string | null>(null);
const [locationAction, setLocationAction] = useState<'geolocate' | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const applyPin = useCallback(async (nextPin: google.maps.LatLngLiteral) => {
setPin(nextPin);
setLocationError(null);
}, []);
const handleRunSearch = async (e: React.FormEvent) => {
e.preventDefault();
setIsSearching(true);
setError(null);
setLocationError(null);
if (!pin) {
setLocationError('Drop a pin on the map or use your current location before running research.');
setIsSearching(false);
return;
}
try {
const response = await runSearch({
name: name.trim() || undefined,
location: `${pin.lat.toFixed(5)}, ${pin.lng.toFixed(5)}`,
radiusKm: radius,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
lat: pin.lat,
lng: pin.lng,
});
onSelectCreatedJob(response.job.id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Research failed.');
} finally {
setIsSearching(false);
}
};
const handleUseMyLocation = () => {
setLocationAction('geolocate');
setLocationError(null);
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
await applyPin({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
} catch (err) {
setLocationError(err instanceof Error ? err.message : 'Failed to use your current location.');
} finally {
setLocationAction(null);
}
},
(geoError) => {
setLocationError(geoError.message || 'Location access was denied.');
setLocationAction(null);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
};
const hasLocationPin = Boolean(pin);
return (
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
<div className="mx-auto max-w-7xl space-y-8">
{topContent}
<header className="space-y-2">
<h1 className="text-3xl font-bold text-stone-900">Basic Research</h1>
<p className="max-w-3xl text-stone-600">
Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below.
</p>
</header>
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
<div className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm sm:p-7">
<form onSubmit={handleRunSearch} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Research Name</label>
<input
type="text"
placeholder="Give this research a memorable name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Business Type</label>
<input
type="text"
required
placeholder="e.g. coffee shop, plumber"
value={businessType}
onChange={(e) => setBusinessType(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
placeholder="e.g. organic, emergency, family-owned"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2.5">
<label className="text-sm font-semibold text-stone-700">Location Source</label>
<button
type="button"
onClick={handleUseMyLocation}
disabled={locationAction !== null}
className="inline-flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<LocateFixed className="h-4 w-4" />
{locationAction === 'geolocate' ? 'Locating...' : 'Use my location'}
</button>
{locationError && (
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{locationError}</span>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Area (km radius)</label>
<input
type="number"
min="1"
max="50"
value={radius}
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-600">
Drop a pin directly on the map or use your current location. The map circle always reflects the current area value.
</div>
{hasLocationPin && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-3.5 text-sm text-emerald-900">
<p className="font-semibold">Active search center</p>
<p className="mt-1">{pin!.lat.toFixed(5)}, {pin!.lng.toFixed(5)}</p>
</div>
)}
{error && (
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-3.5 text-sm text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={isSearching}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-8"
>
{isSearching ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Running Research...
</>
) : (
<>
<Play className="h-5 w-5" />
Run Research
</>
)}
</button>
</form>
</div>
<div className="flex h-full flex-col gap-4">
<BasicResearchMap pin={pin} radiusKm={radius} onPinChange={(nextPin) => void applyPin(nextPin)} />
<div className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm">
<h3 className="text-lg font-bold text-stone-900">Map controls</h3>
<p className="mt-2 text-sm text-stone-600">
Drop a pin directly on the map or use your current location. The circle updates from the Area field and the search runs from the active pin.
</p>
</div>
</div>
</section>
</div>
</div>
);
}