diff --git a/CHANGELOG.md b/CHANGELOG.md index 94216cc..9823ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,25 @@ # Changelog -All notable changes to this project are documented in this file. +All notable changes to this project will be documented in this file. -## 2026-04-12 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +## [2026-04-19] + +### Added +- Added separate `Research` and `Results` workspaces so new runs and saved run browsing live in distinct flows while preserving bundled map selection. +- Added dedicated Basic and Deep Research results views, plus a public landing page and dedicated auth route for the unauthenticated experience. + +### Changed +- Made Basic research map-first by requiring a dropped pin or current location, sending coordinate-based searches through the API, and cleaning up shared map presentation. +- Simplified shared Google Maps rendering by moving Basic, Deep Research, and result review maps onto the same cleaner visual style. + +### Fixed +- Fixed local logout behavior so the session cookie is cleared consistently and optional session-id logout requests can remove the active server session record. + +## [2026-04-12] ### Changed - Improved local development networking by making API env loading work with `.env.local`, adding LAN-friendly API URL fallback behavior, and fixing development CORS handling. @@ -17,7 +34,7 @@ All notable changes to this project are documented in this file. ### Removed - Removed stale local metadata, placeholder postal seeding code, and leftover Supabase-era repository artifacts. -## 2026-03-27 +## [2026-03-27] ### Changed - Migrated the app from a Supabase runtime to a local Fastify API with PostgreSQL, PostGIS, and cookie-based session auth. @@ -32,7 +49,7 @@ All notable changes to this project are documented in this file. ### Removed - Removed the Supabase browser client, Edge Function runtime, and Supabase migration artifacts from the active app stack. -## 2026-03-26 +## [2026-03-26] ### Added - Initial Leads4Less app with React and Vite. diff --git a/package-lock.json b/package-lock.json index 86efadf..6e1f319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "devDependencies": { "@types/node": "^22.14.0", "@types/pg": "^8.20.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", @@ -1597,6 +1599,26 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@vis.gl/react-google-maps": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.7.1.tgz", @@ -1888,6 +1910,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 0babfc7..48326da 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "devDependencies": { "@types/node": "^22.14.0", "@types/pg": "^8.20.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", diff --git a/server/src/auth/sessions.ts b/server/src/auth/sessions.ts index bde18fd..b1d027f 100644 --- a/server/src/auth/sessions.ts +++ b/server/src/auth/sessions.ts @@ -53,9 +53,15 @@ export function setSessionCookie(reply: FastifyReply, token: string, expiresAt: } export function clearSessionCookie(reply: FastifyReply) { - reply.clearCookie(SESSION_COOKIE_NAME, { + const env = getEnv(); + + reply.setCookie(SESSION_COOKIE_NAME, '', { path: '/', + httpOnly: true, sameSite: 'lax', + secure: env.NODE_ENV === 'production', + expires: new Date(0), + maxAge: 0, }); } @@ -89,6 +95,10 @@ export async function deleteSessionByToken(db: DbClient, token: string) { await db.query('delete from public.sessions where token_hash = $1', [hashSessionToken(token)]); } +export async function deleteSessionById(db: DbClient, sessionId: string) { + await db.query('delete from public.sessions where id = $1', [sessionId]); +} + export async function getSessionUserByToken(db: DbClient, token: string) { const tokenHash = hashSessionToken(token); const result = await db.query( diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 8658cfc..37e7e77 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import { ZodError, z } from 'zod'; import { hashPassword, verifyPassword } from '../auth/passwords.js'; -import { clearSessionCookie, createSession, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js'; +import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js'; import { createUser, getUserByEmail, toAppUser } from '../auth/users.js'; import { getDbPool } from '../db/pool.js'; @@ -16,6 +16,10 @@ const loginSchema = z.object({ password: z.string().min(1), }); +const logoutSchema = z.object({ + sessionId: z.string().uuid().optional(), +}); + function getRequestMetadata(request: FastifyRequest) { return { userAgent: request.headers['user-agent'], @@ -101,9 +105,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => { app.post('/auth/logout', async (request, reply) => { const token = getSessionTokenFromRequest(request); + const payload = logoutSchema.safeParse(request.body); + const sessionId = payload.success ? payload.data.sessionId : undefined; + const db = getDbPool(); if (token) { - await deleteSessionByToken(getDbPool(), token); + await deleteSessionByToken(db, token); + } + + if (sessionId) { + await deleteSessionById(db, sessionId); } clearSessionCookie(reply); diff --git a/server/src/routes/search-jobs.ts b/server/src/routes/search-jobs.ts index e39c465..74abfba 100644 --- a/server/src/routes/search-jobs.ts +++ b/server/src/routes/search-jobs.ts @@ -11,6 +11,8 @@ const runSearchSchema = z.object({ radiusKm: z.coerce.number().positive().max(50), businessType: z.string().trim().min(1), keywords: z.string().trim().optional(), + lat: z.number().finite().min(-90).max(90).optional(), + lng: z.number().finite().min(-180).max(180).optional(), }); const jobParamsSchema = z.object({ diff --git a/server/src/search/run-search.ts b/server/src/search/run-search.ts index da6bd46..727c7bd 100644 --- a/server/src/search/run-search.ts +++ b/server/src/search/run-search.ts @@ -66,7 +66,19 @@ async function executeSearchJobAtCoordinates( } export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise { - const job = await createSearchJob(db, userId, payload); + const hasProvidedCoordinates = typeof payload.lat === 'number' && typeof payload.lng === 'number'; + const job = hasProvidedCoordinates + ? await createSearchJobForCoordinates(db, userId, { + name: payload.name || `${payload.businessType} in ${payload.location}`, + city: payload.location, + address: payload.location, + radiusKm: payload.radiusKm, + businessType: payload.businessType, + keywords: payload.keywords, + lat: payload.lat!, + lng: payload.lng!, + }) + : await createSearchJob(db, userId, payload); const jobId = job.id; try { @@ -75,14 +87,19 @@ export async function runSearchForUser(db: Pool, userId: string, payload: RunSea throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.'); } - const geocoded = await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY); - await updateSearchJobCenter(db, jobId, geocoded.lat, geocoded.lng); + const resolvedCenter = hasProvidedCoordinates + ? { lat: payload.lat!, lng: payload.lng! } + : await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY); + + if (!hasProvidedCoordinates) { + await updateSearchJobCenter(db, jobId, resolvedCenter.lat, resolvedCenter.lng); + } const completedJob = await executeSearchJobAtCoordinates(db, { jobId, userId, - lat: geocoded.lat, - lng: geocoded.lng, + lat: resolvedCenter.lat, + lng: resolvedCenter.lng, radiusKm: payload.radiusKm, businessType: payload.businessType, keywords: payload.keywords, diff --git a/server/src/search/types.ts b/server/src/search/types.ts index 2698230..c7a67a0 100644 --- a/server/src/search/types.ts +++ b/server/src/search/types.ts @@ -56,6 +56,8 @@ export type RunSearchInput = { radiusKm: number; businessType: string; keywords?: string; + lat?: number; + lng?: number; }; export type RunSearchResult = { diff --git a/src/App.tsx b/src/App.tsx index 094b91e..3361254 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,26 @@ 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 { DeepResearchView } from './components/DeepResearchView'; +import { + AlertCircle, + ArrowRight, + Briefcase, + Building2, + Check, + LogIn, + Map, + MapPinned, + Search, + ShieldAlert, + Sparkles, + User, + UserPlus, +} from 'lucide-react'; import { Layout, type AppTab } from './components/Layout'; -import { SearchSetup } from './components/SearchSetup'; import { Dashboard } from './components/Dashboard'; import { MapView } from './components/MapView'; -import type { AppUser } from '../shared/types'; +import { ResearchWorkspace } from './components/ResearchWorkspace'; +import { ResultsWorkspace } from './components/ResultsWorkspace'; +import type { SessionUser } from '../shared/types'; import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import { hasApiConfig } from './lib/api'; @@ -14,7 +28,8 @@ const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? ''; const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !== 'YOUR_API_KEY'; export default function App() { - const [user, setUser] = useState(null); + const [publicPage, setPublicPage] = useState<'landing' | 'auth'>(() => getPublicPageFromLocation()); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('setup'); const [selectedJobIds, setSelectedJobIds] = useState([]); @@ -26,6 +41,18 @@ export default function App() { const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); + useEffect(() => { + const handlePopState = () => { + setPublicPage(getPublicPageFromLocation()); + }; + + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, []); + useEffect(() => { let isMounted = true; @@ -109,6 +136,7 @@ export default function App() { }); setUser(nextUser); + navigatePublicPage('landing', setPublicPage); } catch (error) { setAuthError(error instanceof Error ? error.message : 'Authentication failed.'); } finally { @@ -117,11 +145,16 @@ export default function App() { }; const handleLogout = async () => { + const sessionId = user?.sessionId; + + setAuthError(null); + setAuthNotice(null); + setSelectedJobIds([]); + setUser(null); + setActiveTab('setup'); + try { - await signOutWithLocalAuth(); - setUser(null); - setSelectedJobIds([]); - setAuthNotice(null); + await signOutWithLocalAuth(sessionId); } catch (error) { setAuthError(error instanceof Error ? error.message : 'Failed to sign out.'); } @@ -153,130 +186,39 @@ export default function App() { } if (!user) { + const handleSetAuthMode = (mode: 'sign_in' | 'sign_up') => { + setAuthMode(mode); + setAuthError(null); + setAuthNotice(null); + }; + + if (publicPage === 'auth') { + return ( + navigatePublicPage('landing', setPublicPage)} + onSetAuthMode={handleSetAuthMode} + onSubmit={() => void handleLogin()} + /> + ); + } + return ( -
-
-
-
- -
-

Leads4less

-

Create a local account to access your lead workspace.

-
- -
- - -
- - {authError && ( -
- -
-

Authentication Error

-

{authError}

-
-
- )} - - {authNotice && ( -
- {authNotice} -
- )} - -
{ - event.preventDefault(); - void handleLogin(); - }} - > - {authMode === 'sign_up' && ( -
- - setDisplayName(event.target.value)} - placeholder="Your name" - 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" - /> -
- )} - -
- - setEmail(event.target.value)} - placeholder="you@example.com" - 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" - /> -
- -
- - setPassword(event.target.value)} - placeholder="At least 6 characters" - 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" - /> -
- - -
-
-
+ { + handleSetAuthMode(mode); + navigatePublicPage('auth', setPublicPage); + }} + /> ); } @@ -306,16 +248,26 @@ export default function App() { onLogout={() => void handleLogout()} > {activeTab === 'setup' && ( - + )} + {activeTab === 'results' && ( + )} - {activeTab === 'deepResearch' && } {activeTab === 'dashboard' && } {activeTab === 'map' && } @@ -323,6 +275,544 @@ export default function App() { ); } +function getPublicPageFromLocation(): 'landing' | 'auth' { + if (typeof window === 'undefined') { + return 'landing'; + } + + return window.location.pathname === '/auth' ? 'auth' : 'landing'; +} + +function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'landing' | 'auth') => void) { + const nextPath = page === 'auth' ? '/auth' : '/'; + + if (typeof window !== 'undefined' && window.location.pathname !== nextPath) { + window.history.pushState({}, '', nextPath); + } + + setPublicPage(page); +} + +function LandingPage(props: { + onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; +}) { + const { onGoToAuth } = props; + + const featureCards = [ + { + icon: Search, + title: 'Research Runs', + description: 'Search by city, radius, business type, and keywords without juggling spreadsheets or manual lookups.', + }, + { + icon: MapPinned, + title: 'Deep Research', + description: 'Drop one pin and expand intelligently into nearby postal areas to widen market coverage.', + }, + { + icon: Map, + title: 'Clean Map View', + description: 'Review returned businesses on a focused map built for operational decision-making, not map clutter.', + }, + { + icon: Briefcase, + title: 'Lead Workspace', + description: 'Keep past runs, saved businesses, and mapped results in one place for repeatable prospecting.', + }, + ] as const; + + const audienceCards = [ + { + icon: User, + title: 'Personal Use', + description: 'For solo operators, freelancers, and independent prospectors who need a focused local research workflow.', + }, + { + icon: Building2, + title: 'Small Business', + description: 'For agencies and local teams running prospecting every week across multiple service areas.', + }, + { + icon: Sparkles, + title: 'Enterprise', + description: 'For larger organizations that need custom research volume, rollout support, and tailored operating limits.', + }, + ] as const; + + const plans = [ + { + name: 'Personal', + audience: 'For solo operators', + price: '$19', + period: '/month', + cta: 'Start free', + featured: false, + items: ['1 user workspace', '40 research runs / month', 'Map view and dashboard history', 'Email support'], + }, + { + name: 'Small Business', + audience: 'For growing local teams', + price: '$79', + period: '/month', + cta: 'Choose Small Business', + featured: true, + items: ['Everything in Personal', '250 research runs / month', 'Deep research workflows', 'Extended lead history', 'Priority support'], + }, + { + name: 'Enterprise', + audience: 'For custom rollouts', + price: 'Contact', + period: 'sales', + cta: 'Talk to sales', + featured: false, + items: ['Custom research volume', 'Custom onboarding plan', 'Tailored support model', 'Deployment and process guidance'], + }, + ] as const; + + return ( +
+
+
+
+
+
+ +
+
+

Leads4less

+

Local market research for modern teams

+
+
+ + +
+
+ +
+
+
+ + Built for local lead generation workflows +
+ +
+

+ Research local markets, map opportunities, and build better lead lists faster. +

+

+ Run targeted searches, expand coverage from a single pin, and review every result in one focused workspace designed for real prospecting operations. +

+
+ +
+ + + See pricing + +
+
+
+ +
+
+

Product

+

One workspace for local lead generation

+

+ Leads4less keeps market research, deep area expansion, map review, and saved business history in a single operating flow so your team can move faster without losing context. +

+
+ +
+
+

Operational Workflow

+

Search a market, expand intelligently, and review results visually.

+

+ Instead of stitching together Google tabs, spreadsheets, and hand-written notes, run the full prospecting loop from one product surface built for repeatable research. Launch deeper market coverage from a single map interaction while keeping research, deep research, dashboard, and map review connected in one workspace. +

+
+
+

Best Fit

+

Local teams who need speed and structure

+

Built for recurring lead generation, territory research, and targeted market expansion.

+
+
+
+ +
+
+

Workflow

+

A clear research process from first search to mapped review

+
+ +
+ {[ + ['01', 'Search', 'Start with location, radius, business type, and optional keywords.'], + ['02', 'Expand', 'Use deep research to fan out from a pin into nearby postal areas.'], + ['03', 'Review', 'Inspect returned businesses on a clean map and in the dashboard.'], + ['04', 'Act', 'Keep high-value results organized for follow-up and repeated market work.'], + ].map(([step, title, description]) => ( +
+

{step}

+

{title}

+

{description}

+
+ ))} +
+
+ +
+
+

Who It's For

+

Designed for personal use, small business teams, and enterprise rollouts

+
+ +
+ {audienceCards.map((audience) => ( +
+
+ +
+

{audience.title}

+

{audience.description}

+
+ ))} +
+
+ +
+
+

Pricing

+

Choose the plan that matches your research volume

+

+ Start small, run local research consistently, and upgrade when your market coverage or team needs expand. +

+
+ +
+ {plans.map((plan) => ( +
+
+
+

{plan.name}

+

{plan.audience}

+
+ {plan.featured && ( + + Most Popular + + )} +
+ +
+ {plan.price} + {plan.period} +
+ + + +
+ {plan.items.map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+ ))} +
+
+ +
+
+
+

Start Now

+

Turn local market research into a repeatable system.

+

+ Create an account, run your first research job, and build a cleaner lead workflow from day one. +

+
+
+ + + Compare plans + +
+
+
+
+
+ ); +} + +function AuthPage(props: { + authMode: 'sign_in' | 'sign_up'; + authError: string | null; + authNotice: string | null; + displayName: string; + email: string; + isAuthenticating: boolean; + password: string; + onDisplayNameChange: (value: string) => void; + onEmailChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onGoHome: () => void; + onSetAuthMode: (mode: 'sign_in' | 'sign_up') => void; + onSubmit: () => void; +}) { + const { + authMode, + authError, + authNotice, + displayName, + email, + isAuthenticating, + password, + onDisplayNameChange, + onEmailChange, + onPasswordChange, + onGoHome, + onSetAuthMode, + onSubmit, + } = props; + + return ( +
+
+
+
+ + +
+ +
+
+
+ +
+
+
+ + Secure access to your lead workspace +
+ +
+

+ {authMode === 'sign_up' ? 'Create your workspace and start researching local markets.' : 'Sign in and continue your lead research workflow.'} +

+

+ Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface. +

+
+ +
+ {[ + ['Targeted search', 'Run location-based business research with clear inputs and repeatable jobs.'], + ['Map review', 'Inspect returned businesses on a cleaner map built for operational use.'], + ['Persistent history', 'Keep lead runs and saved businesses available whenever you come back.'], + ].map(([title, description]) => ( +
+

{title}

+

{description}

+
+ ))} +
+
+ +
+
+
+

Account Access

+

+ {authMode === 'sign_up' ? 'Create account' : 'Sign in'} +

+

+ {authMode === 'sign_up' ? 'Set up your account to start using Leads4less.' : 'Use your account to continue where you left off.'} +

+
+
+ {authMode === 'sign_up' ? : } +
+
+ +
+ + +
+ + {authError && ( +
+ +
+

Authentication Error

+

{authError}

+
+
+ )} + + {authNotice &&
{authNotice}
} + +
{ + event.preventDefault(); + onSubmit(); + }} + > + {authMode === 'sign_up' && ( +
+ + onDisplayNameChange(event.target.value)} + placeholder="Your name" + className="w-full rounded-2xl 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" + /> +
+ )} + +
+ + onEmailChange(event.target.value)} + placeholder="you@example.com" + className="w-full rounded-2xl 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" + /> +
+ +
+ + onPasswordChange(event.target.value)} + placeholder="At least 6 characters" + className="w-full rounded-2xl 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" + /> +
+ + +
+
+
+
+
+ ); +} + function ConfigScreen(props: { icon: ReactElement; title: string; diff --git a/src/components/BasicResearchMap.tsx b/src/components/BasicResearchMap.tsx new file mode 100644 index 0000000..73a0448 --- /dev/null +++ b/src/components/BasicResearchMap.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useMemo } from 'react'; +import { Map, Marker, useMap } from '@vis.gl/react-google-maps'; +import { cleanMapOptions } from '../lib/map-styles'; + +interface BasicResearchMapProps { + pin: google.maps.LatLngLiteral | null; + radiusKm: number; + onPinChange: (nextPin: google.maps.LatLngLiteral) => void; +} + +export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMapProps) { + const defaultCenter = useMemo(() => pin ?? { lat: 39.5, lng: -98.35 }, [pin]); + + return ( +
+ { + const latLng = event.detail.latLng; + if (latLng) { + onPinChange(latLng); + } + }} + > + + {pin && ( + + )} + + + {!pin && ( +
+ Click anywhere on the map to drop a pin. Use the Area field to adjust the search circle. +
+ )} +
+ ); +} + +function BasicResearchOverlay({ pin, radiusKm }: { pin: google.maps.LatLngLiteral | null; radiusKm: number }) { + const map = useMap(); + + useEffect(() => { + if (!map || !pin) { + return; + } + + const circle = new google.maps.Circle({ + map, + center: pin, + radius: Math.max(1, radiusKm) * 1000, + strokeColor: '#059669', + strokeOpacity: 0.9, + strokeWeight: 2, + fillColor: '#10b981', + fillOpacity: 0.12, + clickable: false, + }); + + const bounds = circle.getBounds(); + if (bounds) { + map.fitBounds(bounds, 60); + } + + return () => { + circle.setMap(null); + }; + }, [map, pin, radiusKm]); + + return null; +} diff --git a/src/components/BasicResultsView.tsx b/src/components/BasicResultsView.tsx new file mode 100644 index 0000000..268f24f --- /dev/null +++ b/src/components/BasicResultsView.tsx @@ -0,0 +1,309 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AlertCircle, + CalendarDays, + Check, + CheckCircle2, + CircleOff, + Clock3, + Loader2, + MapPin, + Search as SearchIcon, + SlidersHorizontal, +} from 'lucide-react'; +import { listSearchJobs } from '../lib/database'; +import type { SearchJob, SearchJobStatus } from '../types'; +import type { AppUser } from '../../shared/types'; + +interface BasicResultsViewProps { + user: AppUser; + selectedJobIds: string[]; + onToggleJobSelection: (jobId: string) => void; + onShowSelectedOnMap: () => void; + onClearSelection: () => void; +} + +type JobStatusFilter = 'all' | SearchJobStatus; +type SortOption = 'newest' | 'oldest' | 'results'; + +const statusOptions: Array<{ value: JobStatusFilter; label: string }> = [ + { value: 'all', label: 'All statuses' }, + { value: 'pending', label: 'Pending' }, + { value: 'running', label: 'Running' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, + { value: 'stopped', label: 'Stopped' }, +]; + +const sortOptions: Array<{ value: SortOption; label: string }> = [ + { value: 'newest', label: 'Newest first' }, + { value: 'oldest', label: 'Oldest first' }, + { value: 'results', label: 'Most results' }, +]; + +export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, onShowSelectedOnMap, onClearSelection }: BasicResultsViewProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortOrder, setSortOrder] = useState('newest'); + const [isLoadingHistory, setIsLoadingHistory] = useState(true); + const [jobs, setJobs] = useState([]); + const [error, setError] = useState(null); + const selectedJobCount = selectedJobIds.length; + + const refreshJobs = useCallback(async () => { + setIsLoadingHistory(true); + + try { + const nextJobs = await listSearchJobs(user.id, 100); + setJobs(nextJobs); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load research jobs.'); + } finally { + setIsLoadingHistory(false); + } + }, [user.id]); + + useEffect(() => { + void refreshJobs(); + }, [refreshJobs]); + + const filteredJobs = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + + const nextJobs = jobs.filter((job) => { + const matchesStatus = statusFilter === 'all' || job.status === statusFilter; + + if (!matchesStatus) { + return false; + } + + if (!normalizedSearch) { + return true; + } + + const haystack = [job.name, job.businessType, job.city, job.address, job.keywords] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(normalizedSearch); + }); + + nextJobs.sort((left, right) => { + if (sortOrder === 'results') { + return right.totalResults - left.totalResults; + } + + const leftTime = new Date(left.createdAt).getTime(); + const rightTime = new Date(right.createdAt).getTime(); + + return sortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime; + }); + + return nextJobs; + }, [jobs, searchTerm, sortOrder, statusFilter]); + + return ( +
+
+
+

Basic Results

+

Basic research runs

+

Filter the grid to find specific runs, select the ones you want, then send the full selection to the map.

+
+
+ {filteredJobs.length} shown of {jobs.length} +
+
+ +
+
+ + + + + +
+ + {selectedJobCount > 0 && ( +
+
+

{selectedJobCount === 1 ? '1 research job selected' : `${selectedJobCount} research jobs selected`}

+

Use the selection action to open all selected jobs together on the map.

+
+
+ + +
+
+ )} +
+ + {error &&
{error}
} + + {isLoadingHistory ? ( +
+ + Loading research jobs... +
+ ) : filteredJobs.length === 0 ? ( +
+

No research jobs match the current filters.

+

Try adjusting the search term, status filter, or sort order.

+
+ ) : ( +
+ {filteredJobs.map((job) => { + const statusMeta = getStatusMeta(job.status); + const isSelected = selectedJobIds.includes(job.id); + + return ( + + ); + })} +
+ )} +
+ ); +} + +function formatLocation(job: SearchJob) { + const primaryLocation = job.address || job.city || 'Location not available'; + return `${primaryLocation} (${job.radiusKm} km radius)`; +} + +function getStatusMeta(status: SearchJobStatus) { + switch (status) { + case 'completed': + return { + label: 'Completed', + icon: CheckCircle2, + badgeClass: 'inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700', + }; + case 'running': + return { + label: 'Running', + icon: Loader2, + badgeClass: 'inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700', + }; + case 'failed': + return { + label: 'Failed', + icon: AlertCircle, + badgeClass: 'inline-flex items-center gap-1 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-semibold text-red-700', + }; + case 'stopped': + return { + label: 'Stopped', + icon: CircleOff, + badgeClass: 'inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-700', + }; + case 'pending': + default: + return { + label: 'Pending', + icon: Clock3, + badgeClass: 'inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700', + }; + } +} diff --git a/src/components/DeepResearchPreviewMap.tsx b/src/components/DeepResearchPreviewMap.tsx index a67244b..cc2d486 100644 --- a/src/components/DeepResearchPreviewMap.tsx +++ b/src/components/DeepResearchPreviewMap.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo } from 'react'; -import { AdvancedMarker, Map, Pin, useMap } from '@vis.gl/react-google-maps'; +import { Map, Marker, useMap } from '@vis.gl/react-google-maps'; import type { DeepResearchPreview } from '../../shared/types'; +import { cleanMapOptions } from '../lib/map-styles'; interface DeepResearchPreviewMapProps { pin: google.maps.LatLngLiteral | null; @@ -29,22 +30,29 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear )} style={{ width: '100%', height: '100%' }} gestureHandling="greedy" + {...cleanMapOptions} onClick={(event) => { const latLng = event.detail.latLng; if (latLng) { onPinChange(latLng); } }} - > + > {pin && ( - - - + )} @@ -97,6 +105,12 @@ function PreviewOverlay({ return; } + if (pin && (!preview || preview.areas.length === 0)) { + map.panTo(pin); + map.setZoom(15); + return; + } + const bounds = new google.maps.LatLngBounds(); let hasBounds = false; @@ -113,7 +127,7 @@ function PreviewOverlay({ }); if (hasBounds) { - map.fitBounds(bounds, 60); + map.fitBounds(bounds, 40); } }, [map, pin, preview]); diff --git a/src/components/DeepResearchResultsView.tsx b/src/components/DeepResearchResultsView.tsx new file mode 100644 index 0000000..5d4e5a9 --- /dev/null +++ b/src/components/DeepResearchResultsView.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import type { DeepResearchBatchSummary } from '../../shared/types'; +import { getDeepResearchBatch, listDeepResearchBatches } from '../lib/database'; + +interface DeepResearchResultsViewProps { + onShowBatchOnMap: (jobIds: string[]) => void; +} + +export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResultsViewProps) { + const [batches, setBatches] = useState([]); + const [error, setError] = useState(null); + const [isLoadingBatches, setIsLoadingBatches] = useState(true); + const [activeBatchId, setActiveBatchId] = useState(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]); + + 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 ( +
+
+
+

Deep Research Results

+

Previous deep research batches

+

Review completed or failed batch runs and open their bundled map results.

+
+
+ {batches.length} batches +
+
+ + {error &&
{error}
} + + {isLoadingBatches ? ( +
+ + Loading deep research batches... +
+ ) : batches.length === 0 ? ( +
+

No deep research batches yet.

+

Preview a pin on the map and run your first deep research batch.

+
+ ) : ( +
+ {batches.map((batch) => ( +
+
+
+

{batch.businessType}

+

{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}

+
+ {batch.status} +
+ +
+
+

Propagation

+

{batch.propagation}

+
+
+

Postal Areas

+

{batch.totalPostalAreas}

+
+
+

Child Jobs

+

{batch.childJobCount}

+
+
+

Leads

+

{batch.totalResults}

+
+
+ + {batch.keywords &&

Keywords: {batch.keywords}

} + +
+ Created {new Date(batch.createdAt).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ ); +} + +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'; + } +} diff --git a/src/components/DeepResearchView.tsx b/src/components/DeepResearchView.tsx index f080c4f..68f6a06 100644 --- a/src/components/DeepResearchView.tsx +++ b/src/components/DeepResearchView.tsx @@ -1,43 +1,23 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { 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 type { DeepResearchPreview } from '../../shared/types'; +import { createDeepResearchBatch, previewDeepResearch } from '../lib/database'; import { DeepResearchPreviewMap } from './DeepResearchPreviewMap'; interface DeepResearchViewProps { onShowBatchOnMap: (jobIds: string[]) => void; + topContent?: React.ReactNode; } -export function DeepResearchView({ onShowBatchOnMap }: DeepResearchViewProps) { +export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchViewProps) { const [pin, setPin] = useState(null); const [businessType, setBusinessType] = useState(''); const [keywords, setKeywords] = useState(''); const [propagation, setPropagation] = useState(1); const [preview, setPreview] = useState(null); - const [batches, setBatches] = useState([]); - const [error, setError] = useState(null); const [previewError, setPreviewError] = useState(null); const [isPreviewing, setIsPreviewing] = useState(false); const [isRunning, setIsRunning] = useState(false); - const [isLoadingBatches, setIsLoadingBatches] = useState(true); - const [activeBatchId, setActiveBatchId] = useState(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); @@ -97,41 +77,21 @@ export function DeepResearchView({ onShowBatchOnMap }: DeepResearchViewProps) { 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 (
+ {topContent} +

Deep Research

@@ -272,101 +232,7 @@ export function DeepResearchView({ onShowBatchOnMap }: DeepResearchViewProps) { )}

- -
-
-
-

Deep Research History

-

Previous deep research batches

-

Review completed or failed batch runs and open their bundled map results.

-
-
- {batches.length} batches -
-
- - {error &&
{error}
} - - {isLoadingBatches ? ( -
- - Loading deep research batches... -
- ) : batches.length === 0 ? ( -
-

No deep research batches yet.

-

Preview a pin on the map and run your first deep research batch.

-
- ) : ( -
- {batches.map((batch) => ( -
-
-
-

{batch.businessType}

-

- {batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'} -

-
- - {batch.status} - -
- -
-
-

Propagation

-

{batch.propagation}

-
-
-

Postal Areas

-

{batch.totalPostalAreas}

-
-
-

Child Jobs

-

{batch.childJobCount}

-
-
-

Leads

-

{batch.totalResults}

-
-
- - {batch.keywords &&

Keywords: {batch.keywords}

} - -
- Created {new Date(batch.createdAt).toLocaleDateString()} - -
-
- ))} -
- )} -
); } - -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'; - } -} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 43a0b2a..4915f84 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, MapPinned } from 'lucide-react'; +import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files } 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'; +export type AppTab = 'setup' | 'results' | 'dashboard' | 'map'; function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -25,7 +25,7 @@ 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: 'results', name: 'Results', icon: Files }, { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, { id: 'map', name: 'Map View', icon: MapIcon }, ] as const; diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 9b143b1..6687a75 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Map, AdvancedMarker, InfoWindow, Pin, useMap } from '@vis.gl/react-google-maps'; +import { InfoWindow, Map, Marker, useMap } from '@vis.gl/react-google-maps'; import { Globe, Loader2, MapPin, Navigation, Phone, Star } from 'lucide-react'; import { listBusinesses, listBusinessesForJobs } from '../lib/database'; +import { cleanMapOptions } from '../lib/map-styles'; import type { Business } from '../types'; import type { AppUser } from '../../shared/types'; @@ -127,24 +128,25 @@ export function MapView({ user, jobIds }: MapViewProps) { )} style={{ width: '100%', height: '100%' }} gestureHandling="greedy" + {...cleanMapOptions} > {businesses.map((business) => typeof business.latitude === 'number' && typeof business.longitude === 'number' ? ( - setSelected(business)} - > - - + icon={{ + path: google.maps.SymbolPath.CIRCLE, + fillColor: selected?.id === business.id ? '#059669' : '#10b981', + fillOpacity: 1, + strokeColor: selected?.id === business.id ? '#064e3b' : '#065f46', + strokeWeight: 2, + scale: selected?.id === business.id ? 8 : 7, + }} + /> ) : null, )} diff --git a/src/components/ResearchWorkspace.tsx b/src/components/ResearchWorkspace.tsx new file mode 100644 index 0000000..4a8a586 --- /dev/null +++ b/src/components/ResearchWorkspace.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { MapPinned, Search } from 'lucide-react'; +import type { AppUser } from '../../shared/types'; +import { DeepResearchView } from './DeepResearchView'; +import { SearchSetup } from './SearchSetup'; + +type ResearchTab = 'research' | 'deepResearch'; + +interface ResearchWorkspaceProps { + user: AppUser; + selectedJobIds: string[]; + onToggleJobSelection: (jobId: string) => void; + onSelectCreatedJob: (jobId: string) => void; + onShowSelectedOnMap: () => void; + onClearSelection: () => void; + onShowBatchOnMap: (jobIds: string[]) => void; +} + +export function ResearchWorkspace({ + user, + selectedJobIds, + onToggleJobSelection, + onSelectCreatedJob, + onShowSelectedOnMap, + onClearSelection, + onShowBatchOnMap, +}: ResearchWorkspaceProps) { + const [activeTab, setActiveTab] = useState('research'); + + const tabs = ( +
+
+
+ {[ + { + id: 'research' as const, + label: 'Basic', + icon: Search, + }, + { + id: 'deepResearch' as const, + label: 'Deep Research', + icon: MapPinned, + }, + ].map((tab) => ( + + ))} +
+
+
+ ); + + return activeTab === 'research' ? ( + + ) : ( + + ); +} diff --git a/src/components/ResultsWorkspace.tsx b/src/components/ResultsWorkspace.tsx new file mode 100644 index 0000000..b468439 --- /dev/null +++ b/src/components/ResultsWorkspace.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { Files, MapPinned } from 'lucide-react'; +import type { AppUser } from '../../shared/types'; +import { BasicResultsView } from './BasicResultsView'; +import { DeepResearchResultsView } from './DeepResearchResultsView'; + +type ResultsTab = 'basic' | 'deepResearch'; + +interface ResultsWorkspaceProps { + user: AppUser; + selectedJobIds: string[]; + onToggleJobSelection: (jobId: string) => void; + onShowSelectedOnMap: () => void; + onClearSelection: () => void; + onShowBatchOnMap: (jobIds: string[]) => void; +} + +export function ResultsWorkspace({ user, selectedJobIds, onToggleJobSelection, onShowSelectedOnMap, onClearSelection, onShowBatchOnMap }: ResultsWorkspaceProps) { + const [activeTab, setActiveTab] = useState('basic'); + + return ( +
+
+
+
+
+ {[ + { id: 'basic' as const, label: 'Basic', icon: Files }, + { id: 'deepResearch' as const, label: 'Deep Research', icon: MapPinned }, + ].map((tab) => ( + + ))} +
+
+
+ +
+

Results

+

Browse previous Basic and Deep Research runs, select items, and send them to the map when needed.

+
+ + {activeTab === 'basic' ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/SearchSetup.tsx b/src/components/SearchSetup.tsx index cf7bce7..cf7e55b 100644 --- a/src/components/SearchSetup.tsx +++ b/src/components/SearchSetup.tsx @@ -1,139 +1,59 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - AlertCircle, - CalendarDays, - Check, - CheckCircle2, - CircleOff, - Clock3, - Loader2, - MapPin, - Play, - Search as SearchIcon, - SlidersHorizontal, -} from 'lucide-react'; -import { listSearchJobs, runSearch } from '../lib/database'; -import type { SearchJob, SearchJobStatus } from '../types'; +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; - selectedJobIds: string[]; - onToggleJobSelection: (jobId: string) => void; onSelectCreatedJob: (jobId: string) => void; - onShowSelectedOnMap: () => void; - onClearSelection: () => void; + topContent?: React.ReactNode; } -type JobStatusFilter = 'all' | SearchJobStatus; -type SortOption = 'newest' | 'oldest' | 'results'; - -const statusOptions: Array<{ value: JobStatusFilter; label: string }> = [ - { value: 'all', label: 'All statuses' }, - { value: 'pending', label: 'Pending' }, - { value: 'running', label: 'Running' }, - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, - { value: 'stopped', label: 'Stopped' }, -]; - -const sortOptions: Array<{ value: SortOption; label: string }> = [ - { value: 'newest', label: 'Newest first' }, - { value: 'oldest', label: 'Oldest first' }, - { value: 'results', label: 'Most results' }, -]; - export function SearchSetup({ - user, - selectedJobIds, - onToggleJobSelection, + user: _user, onSelectCreatedJob, - onShowSelectedOnMap, - onClearSelection, + topContent, }: SearchSetupProps) { const [name, setName] = useState(''); - const [location, setLocation] = useState(''); const [radius, setRadius] = useState(5); const [businessType, setBusinessType] = useState(''); const [keywords, setKeywords] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('newest'); + const [pin, setPin] = useState(null); + const [locationError, setLocationError] = useState(null); + const [locationAction, setLocationAction] = useState<'geolocate' | null>(null); const [isSearching, setIsSearching] = useState(false); - const [isLoadingHistory, setIsLoadingHistory] = useState(true); - const [jobs, setJobs] = useState([]); const [error, setError] = useState(null); - const selectedJobCount = selectedJobIds.length; - const refreshJobs = useCallback(async () => { - setIsLoadingHistory(true); - - try { - const nextJobs = await listSearchJobs(user.id, 100); - setJobs(nextJobs); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load research jobs.'); - } finally { - setIsLoadingHistory(false); - } - }, [user.id]); - - useEffect(() => { - void refreshJobs(); - }, [refreshJobs]); - - const filteredJobs = useMemo(() => { - const normalizedSearch = searchTerm.trim().toLowerCase(); - - const nextJobs = jobs.filter((job) => { - const matchesStatus = statusFilter === 'all' || job.status === statusFilter; - - if (!matchesStatus) { - return false; - } - - if (!normalizedSearch) { - return true; - } - - const haystack = [job.name, job.businessType, job.city, job.address, job.keywords] - .filter(Boolean) - .join(' ') - .toLowerCase(); - - return haystack.includes(normalizedSearch); - }); - - nextJobs.sort((left, right) => { - if (sortOrder === 'results') { - return right.totalResults - left.totalResults; - } - - const leftTime = new Date(left.createdAt).getTime(); - const rightTime = new Date(right.createdAt).getTime(); - - return sortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime; - }); - - return nextJobs; - }, [jobs, searchTerm, sortOrder, statusFilter]); + 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); - try { - const response = await runSearch({ + 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: location.trim(), + location: `${pin.lat.toFixed(5)}, ${pin.lng.toFixed(5)}`, radiusKm: radius, businessType: businessType.trim(), keywords: keywords.trim() || undefined, - }); + lat: pin.lat, + lng: pin.lng, + }); - await refreshJobs(); - onSelectCreatedJob(response.job.id); + onSelectCreatedJob(response.job.id); } catch (err) { setError(err instanceof Error ? err.message : 'Research failed.'); } finally { @@ -141,53 +61,56 @@ export function SearchSetup({ } }; + 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 (
+ {topContent} +
-

Research

+

Basic Research

- Run new research from the form below, then browse every job in a single grid with filters and quick access to map results. + Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below.

-
-
-
-

Research Form

-

Start a new research run

-

- Each run is processed through the local research API, then saved so you can review it later from the dashboard or map view. -

-
-
- -
-
-
- -
- - setLocation(e.target.value)} - className="w-full rounded-xl border border-stone-200 bg-stone-50 py-3 pl-10 pr-4 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" - /> -
-
- +
+
+
- + setRadius(Number.parseInt(e.target.value, 10) || 1)} - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" + 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" />
@@ -199,289 +122,102 @@ export function SearchSetup({ 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-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" + 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" />
-
+
setKeywords(e.target.value)} - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" + 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" />
-
- +
+ + + + {locationError && ( +
+ + {locationError} +
+ )} +
+ +
+ setName(e.target.value)} - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" + 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" />
-
-
- Research runs now go through the local API, so the browser no longer writes leads directly into the database. -
- - {error && ( -
- - {error} +
+ Drop a pin directly on the map or use your current location. The map circle always reflects the current area value.
- )} - - -
-
-
-
-

Research Jobs

-

All research runs

+ {error && ( +
+ + {error} +
+ )} + + + +
+ +
+ void applyPin(nextPin)} /> +
+

Map controls

- Filter the grid to find specific runs, select the ones you want, then send the full selection to the map. + 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.

-
- {filteredJobs.length} shown of {jobs.length} -
- -
-
- - - - - -
- - {selectedJobCount > 0 && ( -
-
-

- {selectedJobCount === 1 ? '1 research job selected' : `${selectedJobCount} research jobs selected`} -

-

Use the selection action to open all selected jobs together on the map.

-
-
- - -
-
- )} -
- - {isLoadingHistory ? ( -
- - Loading research jobs... -
- ) : filteredJobs.length === 0 ? ( -
-

No research jobs match the current filters.

-

Try adjusting the search term, status filter, or sort order.

-
- ) : ( -
- {filteredJobs.map((job) => { - const statusMeta = getStatusMeta(job.status); - const isSelected = selectedJobIds.includes(job.id); - - return ( - - ); - })} -
- )}
); } - -function formatLocation(job: SearchJob) { - const primaryLocation = job.address || job.city || 'Location not available'; - return `${primaryLocation} (${job.radiusKm} km radius)`; -} - -function getStatusMeta(status: SearchJobStatus) { - switch (status) { - case 'completed': - return { - label: 'Completed', - icon: CheckCircle2, - badgeClass: - 'inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700', - }; - case 'running': - return { - label: 'Running', - icon: Loader2, - badgeClass: - 'inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700', - }; - case 'failed': - return { - label: 'Failed', - icon: AlertCircle, - badgeClass: - 'inline-flex items-center gap-1 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-semibold text-red-700', - }; - case 'stopped': - return { - label: 'Stopped', - icon: CircleOff, - badgeClass: - 'inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-700', - }; - case 'pending': - default: - return { - label: 'Pending', - icon: Clock3, - badgeClass: - 'inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700', - }; - } -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a8cf04c..5018f07 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -32,8 +32,9 @@ export async function signInWithLocalAuth(payload: { email: string; password: st return response.user; } -export async function signOutWithLocalAuth() { +export async function signOutWithLocalAuth(sessionId?: string) { await apiRequest<{ success: boolean }>('/auth/logout', { method: 'POST', + body: JSON.stringify(sessionId ? { sessionId } : {}), }); } diff --git a/src/lib/database.ts b/src/lib/database.ts index 4fe8437..94a2a99 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,6 +1,12 @@ import { apiRequest } from './api'; import type { Business, SearchJob } from '../types'; -import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreview, DeepResearchPreviewRequest } from '../../shared/types'; +import type { + CreateDeepResearchBatchRequest, + DeepResearchBatchDetail, + DeepResearchBatchSummary, + DeepResearchPreview, + DeepResearchPreviewRequest, +} from '../../shared/types'; export type SearchJobResultLink = { businessId: string; @@ -13,6 +19,8 @@ export type RunSearchPayload = { radiusKm: number; businessType: string; keywords?: string; + lat?: number; + lng?: number; }; export type RunSearchResponse = { diff --git a/src/lib/map-styles.ts b/src/lib/map-styles.ts new file mode 100644 index 0000000..d93a7d4 --- /dev/null +++ b/src/lib/map-styles.ts @@ -0,0 +1,48 @@ +type CleanMapOptions = Pick; + +export const cleanMapOptions: CleanMapOptions = { + clickableIcons: false, + streetViewControl: false, + fullscreenControl: false, + mapTypeControl: false, + styles: [ + { + featureType: 'poi', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'transit', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'landscape', + elementType: 'labels', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'administrative.neighborhood', + elementType: 'labels', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'administrative.land_parcel', + elementType: 'labels', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'road.highway', + elementType: 'labels', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'road.arterial', + elementType: 'labels.icon', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'road.local', + elementType: 'labels.icon', + stylers: [{ visibility: 'off' }], + }, + ], +}; diff --git a/vite.config.ts b/vite.config.ts index c80aebc..d1470b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, }, server: { + allowedHosts: ['project-1.duramente.com'], hmr: process.env.DISABLE_HMR !== 'true', }, });