244 lines
9.5 KiB
TypeScript
244 lines
9.5 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react';
|
|
import type { DeepResearchPreview } from '@/shared/types';
|
|
import { createDeepResearchBatch, previewDeepResearch } from '../lib/database';
|
|
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
|
|
import { Alert, Badge, Button, FieldLabel, Input, MetricPill, PageContainer, PageShell, SectionHeader, Surface } from './ui';
|
|
|
|
interface DeepResearchViewProps {
|
|
onShowBatchOnMap: (jobIds: string[]) => void;
|
|
topContent?: React.ReactNode;
|
|
}
|
|
|
|
export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchViewProps) {
|
|
const [pin, setPin] = useState<google.maps.LatLngLiteral | null>(null);
|
|
const [businessType, setBusinessType] = useState('');
|
|
const [keywords, setKeywords] = useState('');
|
|
const [propagation, setPropagation] = useState(1);
|
|
const [preview, setPreview] = useState<DeepResearchPreview | null>(null);
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
const [isPreviewing, setIsPreviewing] = useState(false);
|
|
const [isRunning, setIsRunning] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setPreview(null);
|
|
setPreviewError(null);
|
|
}, [businessType, keywords, pin?.lat, pin?.lng, propagation]);
|
|
|
|
const canPreview = Boolean(pin && businessType.trim());
|
|
|
|
const previewSummary = useMemo(() => {
|
|
if (!preview) {
|
|
return null;
|
|
}
|
|
|
|
return `${preview.totalAreas} postal areas across ${preview.countryCode} with ${preview.estimatedChildJobs} child researches.`;
|
|
}, [preview]);
|
|
|
|
const handlePreview = async () => {
|
|
if (!pin || !businessType.trim()) {
|
|
setPreviewError('Drop a pin and add a business type before previewing deep research.');
|
|
return;
|
|
}
|
|
|
|
setIsPreviewing(true);
|
|
setPreviewError(null);
|
|
|
|
try {
|
|
const nextPreview = await previewDeepResearch({
|
|
lat: pin.lat,
|
|
lng: pin.lng,
|
|
propagation,
|
|
businessType: businessType.trim(),
|
|
keywords: keywords.trim() || undefined,
|
|
});
|
|
setPreview(nextPreview);
|
|
} catch (err) {
|
|
setPreviewError(err instanceof Error ? err.message : 'Failed to preview deep research.');
|
|
} finally {
|
|
setIsPreviewing(false);
|
|
}
|
|
};
|
|
|
|
const handleRunDeepResearch = async () => {
|
|
if (!pin || !businessType.trim()) {
|
|
setPreviewError('Drop a pin and add a business type before running deep research.');
|
|
return;
|
|
}
|
|
|
|
setIsRunning(true);
|
|
setPreviewError(null);
|
|
|
|
try {
|
|
const batch = await createDeepResearchBatch({
|
|
lat: pin.lat,
|
|
lng: pin.lng,
|
|
propagation,
|
|
businessType: businessType.trim(),
|
|
keywords: keywords.trim() || undefined,
|
|
});
|
|
|
|
if (batch.jobIds.length > 0) {
|
|
onShowBatchOnMap(batch.jobIds);
|
|
}
|
|
} catch (err) {
|
|
setPreviewError(err instanceof Error ? err.message : 'Failed to run deep research.');
|
|
} finally {
|
|
setIsRunning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageShell>
|
|
<PageContainer>
|
|
{topContent}
|
|
|
|
<SectionHeader
|
|
title="Deep Research"
|
|
description="Drop a pin, choose a propagation depth, preview the ZIP or FSA areas that will be covered, and run one bundled research batch across every adjacent postal area."
|
|
/>
|
|
|
|
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
|
|
<Surface className="p-5 sm:p-7">
|
|
<div className="space-y-5">
|
|
<div className="space-y-2">
|
|
<FieldLabel>Business Type</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
value={businessType}
|
|
onChange={(event) => setBusinessType(event.target.value)}
|
|
placeholder="e.g. dentists, HVAC, bakeries"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<FieldLabel>Keywords</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
value={keywords}
|
|
onChange={(event) => setKeywords(event.target.value)}
|
|
placeholder="Optional comma-separated keywords"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<FieldLabel>Location</FieldLabel>
|
|
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600">
|
|
Click directly on the map to place the deep research center. The preview uses that active pin to find the base ZIP or FSA and expand outward.
|
|
</div>
|
|
</div>
|
|
|
|
{pin && (
|
|
<Alert variant="success" title="Active research center">
|
|
<p>{pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<FieldLabel>Propagation</FieldLabel>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="5"
|
|
value={propagation}
|
|
onChange={(event) => setPropagation(Number.parseInt(event.target.value, 10) || 0)}
|
|
className="w-full accent-emerald-600"
|
|
/>
|
|
<div className="mt-2 flex items-center justify-between text-xs text-stone-500">
|
|
<span>Base postal area only</span>
|
|
<Badge variant="primary">{propagation} hop{propagation === 1 ? '' : 's'}</Badge>
|
|
<span>Expand outward</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600">
|
|
<div className="flex items-center gap-2 font-semibold text-stone-900">
|
|
<Crosshair className="h-4 w-4 text-emerald-600" />
|
|
Pin placement
|
|
</div>
|
|
<p className="mt-2">Click the map to drop a pin. The preview will find the containing ZIP or FSA and expand to adjacent postal areas based on the propagation depth.</p>
|
|
{pin && <p className="mt-3 font-medium text-stone-800">Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>}
|
|
</div>
|
|
|
|
{previewError && (
|
|
<Alert variant="error">{previewError}</Alert>
|
|
)}
|
|
|
|
{previewSummary && (
|
|
<Alert variant="success" title="Preview ready">
|
|
<p>{previewSummary}</p>
|
|
<p className="mt-2 text-emerald-800">
|
|
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
|
|
</p>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handlePreview()}
|
|
disabled={!canPreview || isPreviewing || isRunning}
|
|
variant="secondary"
|
|
className="flex-1"
|
|
>
|
|
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
|
|
Preview areas
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleRunDeepResearch()}
|
|
disabled={!canPreview || isPreviewing || isRunning}
|
|
className="flex-1"
|
|
>
|
|
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
Run deep research
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Surface>
|
|
|
|
<div className="flex h-full flex-col gap-4">
|
|
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
|
|
|
|
<Surface className="p-5">
|
|
<div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">
|
|
<MapPinned className="h-4 w-4" />
|
|
Map Controls
|
|
</div>
|
|
<h3 className="mt-3 text-lg font-semibold text-stone-950">Preview center and coverage</h3>
|
|
<p className="mt-2 text-sm text-stone-600">
|
|
Drop a pin directly on the map to define the base postal area. Preview colors and rings show how propagation expands the deep research batch into adjacent areas.
|
|
</p>
|
|
</Surface>
|
|
|
|
{preview && (
|
|
<Surface className="p-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-stone-950">Preview coverage</h3>
|
|
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
|
|
</div>
|
|
<MetricPill className="bg-stone-50 text-stone-700 shadow-none">
|
|
{preview.totalAreas} areas
|
|
</MetricPill>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{preview.areas.map((area) => (
|
|
<Badge
|
|
key={area.id}
|
|
variant={area.propagationRing === 0 ? 'primary' : 'neutral'}
|
|
>
|
|
{area.displayName} · ring {area.propagationRing}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</Surface>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</PageContainer>
|
|
</PageShell>
|
|
);
|
|
}
|