224 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|