feat: add deep research planning and postal batch runs
Add a dedicated Deep Research view with postal-area preview overlays, batch execution, and bundled map results. Also add postal dataset import tooling and fix local API networking and research insert issues needed to support the new workflow.
This commit is contained in:
+26
-19
@@ -1,7 +1,8 @@
|
||||
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
|
||||
import { APIProvider } from '@vis.gl/react-google-maps';
|
||||
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
|
||||
import { Layout } from './components/Layout';
|
||||
import { DeepResearchView } from './components/DeepResearchView';
|
||||
import { Layout, type AppTab } from './components/Layout';
|
||||
import { SearchSetup } from './components/SearchSetup';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { MapView } from './components/MapView';
|
||||
@@ -15,7 +16,7 @@ const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !==
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<AppUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('setup');
|
||||
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [authNotice, setAuthNotice] = useState<string | null>(null);
|
||||
@@ -79,6 +80,11 @@ export default function App() {
|
||||
setSelectedJobIds([]);
|
||||
};
|
||||
|
||||
const handleShowJobIdsOnMap = (jobIds: string[]) => {
|
||||
setSelectedJobIds(jobIds);
|
||||
setActiveTab('map');
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setAuthError(null);
|
||||
setAuthNotice(null);
|
||||
@@ -146,23 +152,6 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValidMapsKey) {
|
||||
return (
|
||||
<ConfigScreen
|
||||
icon={<MapIcon className="h-8 w-8" />}
|
||||
title="Google Maps API Key Required"
|
||||
description="Add a browser key for map rendering and a server key for the local search API."
|
||||
steps={[
|
||||
'Create a Google Maps Platform API key for the browser app.',
|
||||
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
|
||||
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
|
||||
'Enable Geocoding API and Places API in Google Cloud.',
|
||||
]}
|
||||
footer="The app will start once the browser key is available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-stone-50 p-4">
|
||||
@@ -291,6 +280,23 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValidMapsKey) {
|
||||
return (
|
||||
<ConfigScreen
|
||||
icon={<MapIcon className="h-8 w-8" />}
|
||||
title="Google Maps API Key Required"
|
||||
description="Add a browser key for map rendering and a server key for the local search API."
|
||||
steps={[
|
||||
'Create a Google Maps Platform API key for the browser app.',
|
||||
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
|
||||
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
|
||||
'Enable Geocoding API and Places API in Google Cloud.',
|
||||
]}
|
||||
footer="You can create a local account without the maps key, but the workspace needs it before loading."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY} version="weekly">
|
||||
<Layout
|
||||
@@ -309,6 +315,7 @@ export default function App() {
|
||||
onClearSelection={handleClearSelectedJobs}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'deepResearch' && <DeepResearchView onShowBatchOnMap={handleShowJobIdsOnMap} />}
|
||||
{activeTab === 'dashboard' && <Dashboard user={user} />}
|
||||
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { AdvancedMarker, Map, Pin, useMap } from '@vis.gl/react-google-maps';
|
||||
import type { DeepResearchPreview } from '../../shared/types';
|
||||
|
||||
interface DeepResearchPreviewMapProps {
|
||||
pin: google.maps.LatLngLiteral | null;
|
||||
preview: DeepResearchPreview | null;
|
||||
onPinChange: (nextPin: google.maps.LatLngLiteral) => void;
|
||||
}
|
||||
|
||||
const ringPalette = ['#059669', '#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5'];
|
||||
|
||||
export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResearchPreviewMapProps) {
|
||||
const defaultCenter = useMemo(() => {
|
||||
if (pin) {
|
||||
return pin;
|
||||
}
|
||||
|
||||
const baseArea = preview?.baseArea;
|
||||
if (baseArea?.centroidLat != null && baseArea?.centroidLng != null) {
|
||||
return { lat: baseArea.centroidLat, lng: baseArea.centroidLng };
|
||||
}
|
||||
|
||||
return { lat: 39.5, lng: -98.35 };
|
||||
}, [pin, preview]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[440px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm">
|
||||
<Map
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={5}
|
||||
mapId="DEMO_MAP_ID"
|
||||
{...({ internalUsageAttributionIds: ['gmp_mcp_codeassist_v1_aistudio'] } as Record<string, unknown>)}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
gestureHandling="greedy"
|
||||
onClick={(event) => {
|
||||
const latLng = event.detail.latLng;
|
||||
if (latLng) {
|
||||
onPinChange(latLng);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PreviewOverlay overlay={preview?.overlay ?? null} pin={pin} preview={preview} />
|
||||
{pin && (
|
||||
<AdvancedMarker position={pin}>
|
||||
<Pin background="#059669" glyphColor="#fff" borderColor="#064e3b" />
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
{!pin && (
|
||||
<div className="pointer-events-none absolute inset-x-6 top-6 rounded-2xl border border-white/20 bg-white/90 p-4 text-sm text-stone-600 shadow-lg backdrop-blur-sm">
|
||||
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewOverlay({
|
||||
overlay,
|
||||
pin,
|
||||
preview,
|
||||
}: {
|
||||
overlay: DeepResearchPreview['overlay'] | null;
|
||||
pin: google.maps.LatLngLiteral | null;
|
||||
preview: DeepResearchPreview | null;
|
||||
}) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addedFeatures = map.data.addGeoJson(overlay as never);
|
||||
map.data.setStyle((feature) => {
|
||||
const ring = Number(feature.getProperty('propagationRing') ?? 0);
|
||||
const color = ringPalette[Math.min(ring, ringPalette.length - 1)];
|
||||
|
||||
return {
|
||||
fillColor: color,
|
||||
fillOpacity: ring === 0 ? 0.28 : 0.16,
|
||||
strokeColor: color,
|
||||
strokeWeight: ring === 0 ? 3 : 2,
|
||||
clickable: false,
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
addedFeatures.forEach((feature) => map.data.remove(feature));
|
||||
};
|
||||
}, [map, overlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
let hasBounds = false;
|
||||
|
||||
if (pin) {
|
||||
bounds.extend(pin);
|
||||
hasBounds = true;
|
||||
}
|
||||
|
||||
preview?.areas.forEach((area) => {
|
||||
if (area.centroidLat != null && area.centroidLng != null) {
|
||||
bounds.extend({ lat: area.centroidLat, lng: area.centroidLng });
|
||||
hasBounds = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasBounds) {
|
||||
map.fitBounds(bounds, 60);
|
||||
}
|
||||
}, [map, pin, preview]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react';
|
||||
import type { DeepResearchBatchSummary, DeepResearchPreview } from '../../shared/types';
|
||||
import { createDeepResearchBatch, getDeepResearchBatch, listDeepResearchBatches, previewDeepResearch } from '../lib/database';
|
||||
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
|
||||
|
||||
interface DeepResearchViewProps {
|
||||
onShowBatchOnMap: (jobIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function DeepResearchView({ onShowBatchOnMap }: 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 [batches, setBatches] = useState<DeepResearchBatchSummary[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isLoadingBatches, setIsLoadingBatches] = useState(true);
|
||||
const [activeBatchId, setActiveBatchId] = useState<string | null>(null);
|
||||
|
||||
const refreshBatches = useCallback(async () => {
|
||||
setIsLoadingBatches(true);
|
||||
|
||||
try {
|
||||
const nextBatches = await listDeepResearchBatches();
|
||||
setBatches(nextBatches);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load deep research history.');
|
||||
} finally {
|
||||
setIsLoadingBatches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBatches();
|
||||
}, [refreshBatches]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
setActiveBatchId(batch.id);
|
||||
await refreshBatches();
|
||||
if (batch.jobIds.length > 0) {
|
||||
onShowBatchOnMap(batch.jobIds);
|
||||
}
|
||||
} catch (err) {
|
||||
setPreviewError(err instanceof Error ? err.message : 'Failed to run deep research.');
|
||||
} finally {
|
||||
setActiveBatchId(null);
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBatchOnMap = async (batchId: string) => {
|
||||
setActiveBatchId(batchId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const batch = await getDeepResearchBatch(batchId);
|
||||
if (batch.jobIds.length === 0) {
|
||||
setError('This deep research batch does not have child jobs yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
onShowBatchOnMap(batch.jobIds);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load batch details.');
|
||||
} finally {
|
||||
setActiveBatchId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Deep Research</h1>
|
||||
<p className="max-w-3xl text-stone-600">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="space-y-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Planner</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Configure the deep research batch</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
The preview uses local postal boundaries to determine which child research jobs will be created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Business Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={businessType}
|
||||
onChange={(event) => setBusinessType(event.target.value)}
|
||||
placeholder="e.g. dentists, HVAC, bakeries"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Keywords</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={(event) => setKeywords(event.target.value)}
|
||||
placeholder="Optional comma-separated keywords"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Propagation</label>
|
||||
<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>
|
||||
<span className="rounded-full bg-emerald-50 px-3 py-1 font-semibold text-emerald-700">{propagation} hop{propagation === 1 ? '' : 's'}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<span>{previewError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewSummary && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 text-sm text-emerald-900">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Preview ready
|
||||
</div>
|
||||
<p className="mt-2">{previewSummary}</p>
|
||||
<p className="mt-2 text-emerald-800">
|
||||
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePreview()}
|
||||
disabled={!canPreview || isPreviewing || isRunning}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-700 transition-all hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{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 flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
Run deep research
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
|
||||
|
||||
{preview && (
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-900">Preview coverage</h3>
|
||||
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-medium text-stone-700">
|
||||
{preview.totalAreas} areas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{preview.areas.map((area) => (
|
||||
<span
|
||||
key={area.id}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
area.propagationRing === 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-stone-100 text-stone-700'
|
||||
}`}
|
||||
>
|
||||
{area.displayName} · ring {area.propagationRing}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Deep Research History</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Previous deep research batches</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">Review completed or failed batch runs and open their bundled map results.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
|
||||
{batches.length} batches
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
{isLoadingBatches ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-white p-5 text-sm text-stone-500 shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading deep research batches...
|
||||
</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
|
||||
<p className="text-lg font-semibold text-stone-900">No deep research batches yet.</p>
|
||||
<p className="mt-2 text-sm text-stone-500">Preview a pin on the map and run your first deep research batch.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{batches.map((batch) => (
|
||||
<div key={batch.id} className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-stone-900">{batch.businessType}</p>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusBadgeClass(batch.status)}`}>
|
||||
{batch.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Propagation</p>
|
||||
<p className="mt-1 text-base font-bold text-stone-900">{batch.propagation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Postal Areas</p>
|
||||
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalPostalAreas}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Child Jobs</p>
|
||||
<p className="mt-1 text-base font-bold text-stone-900">{batch.childJobCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Leads</p>
|
||||
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalResults}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.keywords && <p className="mt-4 text-sm text-stone-500">Keywords: {batch.keywords}</p>}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm text-stone-500">
|
||||
<span>Created {new Date(batch.createdAt).toLocaleDateString()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenBatchOnMap(batch.id)}
|
||||
disabled={activeBatchId === batch.id}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2 font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{activeBatchId === batch.id ? 'Loading...' : 'Show bundle on map'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: DeepResearchBatchSummary['status']) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-emerald-100 text-emerald-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-700';
|
||||
case 'running':
|
||||
return 'bg-sky-100 text-sky-700';
|
||||
case 'stopped':
|
||||
return 'bg-stone-200 text-stone-700';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'bg-amber-100 text-amber-700';
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase } from 'lucide-react';
|
||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, MapPinned } from 'lucide-react';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { AppUser } from '../../shared/types';
|
||||
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
||||
|
||||
export type AppTab = 'setup' | 'deepResearch' | 'dashboard' | 'map';
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
user: AppUser;
|
||||
activeTab: 'setup' | 'dashboard' | 'map';
|
||||
setActiveTab: (tab: 'setup' | 'dashboard' | 'map') => void;
|
||||
activeTab: AppTab;
|
||||
setActiveTab: (tab: AppTab) => void;
|
||||
onLogout: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -23,9 +25,10 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
||||
|
||||
const navigation = [
|
||||
{ id: 'setup', name: 'Research', icon: Search },
|
||||
{ id: 'deepResearch', name: 'Deep Research', icon: MapPinned },
|
||||
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'map', name: 'Map View', icon: MapIcon },
|
||||
];
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-stone-50 overflow-hidden">
|
||||
@@ -42,7 +45,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
||||
{navigation.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id as any)}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all",
|
||||
activeTab === item.id
|
||||
|
||||
+53
-9
@@ -1,4 +1,39 @@
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
function isLoopbackHostname(hostname: string) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveApiBaseUrl() {
|
||||
const configuredBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').trim();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return normalizeBaseUrl(configuredBaseUrl);
|
||||
}
|
||||
|
||||
const fallbackBaseUrl = `${window.location.protocol}//${window.location.hostname}:4000/api`;
|
||||
|
||||
if (!configuredBaseUrl) {
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const configuredUrl = new URL(configuredBaseUrl);
|
||||
|
||||
if (isLoopbackHostname(configuredUrl.hostname) && !isLoopbackHostname(window.location.hostname)) {
|
||||
configuredUrl.hostname = window.location.hostname;
|
||||
return normalizeBaseUrl(configuredUrl.toString());
|
||||
}
|
||||
|
||||
return normalizeBaseUrl(configuredUrl.toString());
|
||||
} catch {
|
||||
return normalizeBaseUrl(configuredBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = resolveApiBaseUrl();
|
||||
|
||||
export const hasApiConfig = Boolean(apiBaseUrl);
|
||||
|
||||
@@ -7,14 +42,23 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
|
||||
throw new Error('VITE_API_BASE_URL is not configured.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to reach the local API at ${apiBaseUrl}. Check that the API server is running and that VITE_API_BASE_URL matches the host you opened in the browser.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const payload = contentType.includes('application/json') ? ((await response.json()) as unknown) : null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiRequest } from './api';
|
||||
import type { Business, SearchJob } from '../types';
|
||||
import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreview, DeepResearchPreviewRequest } from '../../shared/types';
|
||||
|
||||
export type SearchJobResultLink = {
|
||||
businessId: string;
|
||||
@@ -58,3 +59,31 @@ export async function runSearch(payload: RunSearchPayload): Promise<RunSearchRes
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewDeepResearch(payload: DeepResearchPreviewRequest): Promise<DeepResearchPreview> {
|
||||
const response = await apiRequest<{ preview: DeepResearchPreview }>('/deep-research/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return response.preview;
|
||||
}
|
||||
|
||||
export async function createDeepResearchBatch(payload: CreateDeepResearchBatchRequest): Promise<DeepResearchBatchDetail> {
|
||||
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>('/deep-research/batches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return response.batch;
|
||||
}
|
||||
|
||||
export async function listDeepResearchBatches(): Promise<DeepResearchBatchSummary[]> {
|
||||
const response = await apiRequest<{ batches: DeepResearchBatchSummary[] }>('/deep-research/batches');
|
||||
return response.batches;
|
||||
}
|
||||
|
||||
export async function getDeepResearchBatch(batchId: string): Promise<DeepResearchBatchDetail> {
|
||||
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>(`/deep-research/batches/${batchId}`);
|
||||
return response.batch;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user