Public Access
1
0

feat: add Supabase-backed local lead finder app

This commit is contained in:
pguerrerox
2026-03-26 22:55:43 +00:00
commit 0e4910805a
23 changed files with 4773 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Frontend env vars for the Vite app
VITE_SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
# Backend / Edge Function secrets
# Do not expose these in the browser.
SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
SUPABASE_SERVICE_ROLE_KEY="YOUR_SUPABASE_SERVICE_ROLE_KEY"
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
+49
View File
@@ -0,0 +1,49 @@
# Leads4Less
Leads4Less is a React + Vite app for finding local business leads, saving them in Supabase, and browsing them in dashboard and map views.
## Stack
- React 19 + Vite
- Supabase Auth + Postgres + Edge Functions
- Google Maps Platform for maps, geocoding, and Places search
## Local App Setup
1. Install dependencies:
`npm install`
2. Copy `.env.example` to `.env.local` and fill in:
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_ANON_KEY`
- `VITE_GOOGLE_MAPS_PLATFORM_KEY`
3. Run the app:
`npm run dev`
## Supabase Setup
1. Create a Supabase project.
2. Enable email/password auth in Supabase Auth.
3. Apply the SQL migration in `supabase/migrations/20260322120000_init.sql`.
4. Deploy the Edge Function in `supabase/functions/run-search/index.ts`.
5. Set these Edge Function secrets in Supabase:
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `GOOGLE_MAPS_SERVER_KEY`
## Google Maps Requirements
Enable these Google Cloud APIs for the keys you use:
- Maps JavaScript API
- Places API
- Geocoding API
Use a browser-restricted key for `VITE_GOOGLE_MAPS_PLATFORM_KEY` and a server-side key for `GOOGLE_MAPS_SERVER_KEY`.
## Current Flow
1. User signs in with Supabase email/password auth.
2. The app submits a search request to the `run-search` Edge Function.
3. The function geocodes the location, calls Google Places, upserts businesses, and stores job results in Supabase.
4. The dashboard and map load saved leads from Postgres.
View File
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leads4Less</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Leads4Less",
"description": "A web application that collects local business information for a given location and business category, stores it in a searchable database, and presents it in a dashboard, table, and map view for lead generation and list building.",
"requestFramePermissions": ["geolocation"]
}
+2526
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "leads4less",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@supabase/supabase-js": "^2.57.4",
"@tailwindcss/vite": "^4.1.14",
"@vis.gl/react-google-maps": "^1.7.1",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"lucide-react": "^0.546.0",
"papaparse": "^5.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+367
View File
@@ -0,0 +1,367 @@
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
import { hasSupabaseConfig, supabase } from './lib/supabase';
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<User | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
useEffect(() => {
let isMounted = true;
const loadSession = async () => {
const { data, error } = await supabase.auth.getSession();
if (!isMounted) {
return;
}
if (error) {
setAuthError(error.message);
}
setUser(data.session?.user ?? null);
setLoading(false);
};
void loadSession();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!isMounted) {
return;
}
setUser(session?.user ?? null);
setLoading(false);
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const handleSelectJob = (jobId: string) => {
setSelectedJobId(jobId);
setActiveTab('map');
};
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
setIsAuthenticating(true);
if (authMode === 'sign_up') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: displayName.trim() || undefined,
},
},
});
if (error) {
setAuthError(error.message);
setIsAuthenticating(false);
return;
}
setAuthNotice(
data.session
? 'Account created and signed in.'
: 'Account created. If email confirmation is enabled, check your inbox before signing in.',
);
setIsAuthenticating(false);
return;
}
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setAuthError(error.message);
setIsAuthenticating(false);
return;
}
setIsAuthenticating(false);
};
const handleLogout = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
setAuthError(error.message);
return;
}
setSelectedJobId(null);
setAuthNotice(null);
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-stone-50">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent"></div>
</div>
);
}
if (!hasSupabaseConfig) {
return (
<ConfigScreen
icon={<ShieldAlert className="h-8 w-8" />}
title="Supabase Config Required"
description="Add your Supabase project URL and anon key before running the app."
steps={[
'Create a Supabase project.',
'Add VITE_SUPABASE_URL to your local environment.',
'Add VITE_SUPABASE_ANON_KEY to your local environment.',
'Restart the Vite dev server after updating env vars.',
]}
footer="The app uses Supabase Auth, Postgres, and Edge Functions now."
/>
);
}
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 Supabase search function."
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 in Supabase Edge Function secrets.',
'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">
<div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<Briefcase className="h-8 w-8" />
</div>
<h1 className="mt-6 text-3xl font-bold tracking-tight text-stone-900">Lead Finder</h1>
<p className="mt-2 text-stone-600">Use Supabase email auth to access your lead workspace.</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-xl bg-stone-100 p-1">
<button
type="button"
onClick={() => {
setAuthMode('sign_in');
setAuthError(null);
setAuthNotice(null);
}}
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-all ${
authMode === 'sign_in' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-900'
}`}
>
Sign In
</button>
<button
type="button"
onClick={() => {
setAuthMode('sign_up');
setAuthError(null);
setAuthNotice(null);
}}
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-all ${
authMode === 'sign_up' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-900'
}`}
>
Create Account
</button>
</div>
{authError && (
<div className="flex items-start gap-3 rounded-xl 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" />
<div>
<p className="font-semibold">Authentication Error</p>
<p className="mt-1 opacity-90">{authError}</p>
</div>
</div>
)}
{authNotice && (
<div className="rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700">
{authNotice}
</div>
)}
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void handleLogin();
}}
>
{authMode === 'sign_up' && (
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Name</label>
<input
type="text"
value={displayName}
onChange={(event) => 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"
/>
</div>
)}
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Email</label>
<input
type="email"
required
value={email}
onChange={(event) => 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"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Password</label>
<input
type="password"
required
minLength={6}
value={password}
onChange={(event) => 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"
/>
</div>
<button
type="submit"
disabled={isAuthenticating}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isAuthenticating ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
) : authMode === 'sign_up' ? (
<UserPlus className="h-5 w-5" />
) : (
<LogIn className="h-5 w-5" />
)}
{isAuthenticating
? authMode === 'sign_up'
? 'Creating account...'
: 'Signing in...'
: authMode === 'sign_up'
? 'Create Account'
: 'Sign In'}
</button>
</form>
</div>
</div>
);
}
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY} version="weekly">
<Layout
user={user}
activeTab={activeTab}
setActiveTab={(tab) => {
setActiveTab(tab);
if (tab !== 'map') {
setSelectedJobId(null);
}
}}
onLogout={() => void handleLogout()}
>
{activeTab === 'setup' && <SearchSetup user={user} onSelectJob={handleSelectJob} />}
{activeTab === 'dashboard' && <Dashboard user={user} />}
{activeTab === 'map' && <MapView user={user} jobId={selectedJobId} />}
</Layout>
</APIProvider>
);
}
function ConfigScreen(props: {
icon: ReactElement;
title: string;
description: string;
steps: string[];
footer: string;
}) {
const { icon, title, description, steps, footer } = props;
return (
<div className="flex h-screen items-center justify-center bg-stone-50 p-6 font-sans">
<div className="w-full max-w-lg rounded-2xl bg-white p-8 text-center shadow-xl">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
{icon}
</div>
<h2 className="mb-4 text-2xl font-bold text-stone-900">{title}</h2>
<p className="mb-6 text-left text-stone-600">{description}</p>
<div className="mb-6 space-y-4 rounded-xl border border-stone-200 bg-stone-50 p-6 text-left">
<p className="text-sm font-medium text-stone-900">Follow these steps:</p>
<ol className="list-inside list-decimal space-y-2 text-sm text-stone-600">
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
<p className="text-xs text-stone-400">{footer}</p>
</div>
</div>
);
}
function MapIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" y1="3" x2="9" y2="18" />
<line x1="15" y1="6" x2="15" y2="21" />
</svg>
);
}
+364
View File
@@ -0,0 +1,364 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import Papa from 'papaparse';
import {
ArrowUpDown,
Briefcase,
ChevronLeft,
ChevronRight,
Download,
Globe,
Loader2,
Phone,
Star,
} from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database';
import type { Business, SearchJob } from '../types';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface DashboardProps {
user: User;
}
export function Dashboard({ user }: DashboardProps) {
const [businesses, setBusinesses] = useState<Business[]>([]);
const [jobs, setJobs] = useState<SearchJob[]>([]);
const [jobLinks, setJobLinks] = useState<SearchJobResultLink[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterJobId, setFilterJobId] = useState<string>('all');
const [filterHasWebsite, setFilterHasWebsite] = useState(false);
const [filterHasPhone, setFilterHasPhone] = useState(false);
const [sortField, setSortField] = useState<keyof Business>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
useEffect(() => {
const loadDashboard = async () => {
setLoading(true);
setError(null);
try {
const [nextBusinesses, nextJobs, nextLinks] = await Promise.all([
listBusinesses(user.id),
listSearchJobs(user.id, 100),
listJobResultLinks(user.id),
]);
setBusinesses(nextBusinesses);
setJobs(nextJobs);
setJobLinks(nextLinks);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard data.');
} finally {
setLoading(false);
}
};
void loadDashboard();
}, [user.id]);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterJobId, filterHasWebsite, filterHasPhone, sortField, sortDirection]);
const filteredBusinesses = useMemo(() => {
const allowedIds =
filterJobId === 'all'
? null
: new Set(jobLinks.filter((link) => link.searchJobId === filterJobId).map((link) => link.businessId));
return businesses
.filter((business) => {
const matchesSearch =
business.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
business.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
business.category?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesWebsite = !filterHasWebsite || Boolean(business.website);
const matchesPhone = !filterHasPhone || Boolean(business.phone);
const matchesJob = !allowedIds || allowedIds.has(business.id);
return matchesSearch && matchesWebsite && matchesPhone && matchesJob;
})
.sort((a, b) => {
const aVal = a[sortField] ?? '';
const bVal = b[sortField] ?? '';
if (aVal < bVal) {
return sortDirection === 'asc' ? -1 : 1;
}
if (aVal > bVal) {
return sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
}, [businesses, filterHasPhone, filterHasWebsite, filterJobId, jobLinks, searchTerm, sortDirection, sortField]);
const paginatedBusinesses = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredBusinesses.slice(start, start + itemsPerPage);
}, [currentPage, filteredBusinesses]);
const totalPages = Math.max(1, Math.ceil(filteredBusinesses.length / itemsPerPage));
const kpis = useMemo(() => {
const total = businesses.length;
const withWebsite = businesses.filter((business) => Boolean(business.website)).length;
const withPhone = businesses.filter((business) => Boolean(business.phone)).length;
const ratedBusinesses = businesses.filter((business) => typeof business.rating === 'number');
const avgRating = ratedBusinesses.length
? ratedBusinesses.reduce((sum, business) => sum + (business.rating ?? 0), 0) / ratedBusinesses.length
: 0;
return [
{ name: 'Total Leads', value: total, icon: Briefcase, color: 'bg-blue-500' },
{ name: 'With Website', value: withWebsite, icon: Globe, color: 'bg-emerald-500' },
{ name: 'With Phone', value: withPhone, icon: Phone, color: 'bg-orange-500' },
{ name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star, color: 'bg-amber-500' },
];
}, [businesses]);
const handleExport = () => {
const csv = Papa.unparse(
filteredBusinesses.map((business) => ({
Name: business.name,
Address: business.address,
City: business.city,
State: business.stateProvince,
PostalCode: business.postalCode,
Country: business.country,
Phone: business.phone,
Website: business.website,
Rating: business.rating,
Reviews: business.reviewCount,
Category: business.category,
Latitude: business.latitude,
Longitude: business.longitude,
Source: business.source,
CreatedAt: business.createdAt,
})),
);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `leads_export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const toggleSort = (field: keyof Business) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
return;
}
setSortField(field);
setSortDirection('asc');
};
if (loading) {
return (
<div className="flex flex-1 items-center justify-center bg-stone-50">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
);
}
return (
<div className="flex-1 overflow-y-auto bg-stone-50 p-8">
<div className="mx-auto max-w-7xl space-y-8">
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight text-stone-900">Lead Dashboard</h1>
<p className="mt-1 text-stone-600">Browse Supabase-backed search results and export targeted lead lists.</p>
</div>
<button
onClick={handleExport}
className="flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-6 py-3 font-semibold text-white shadow-sm transition-all hover:bg-stone-800"
>
<Download className="h-5 w-5" />
Export CSV
</button>
</header>
{error && (
<div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((kpi) => (
<div key={kpi.name} className="flex items-center gap-4 rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div className={cn('rounded-xl p-3 text-white', kpi.color)}>
<kpi.icon className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-medium text-stone-500">{kpi.name}</p>
<p className="text-2xl font-bold text-stone-900">{kpi.value}</p>
</div>
</div>
))}
</div>
<div className="space-y-4 rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
<input
type="text"
placeholder="Search by name, city, or category..."
value={searchTerm}
onChange={(e) => setSearchTerm(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"
/>
<select
value={filterJobId}
onChange={(e) => setFilterJobId(e.target.value)}
className="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"
>
<option value="all">All jobs</option>
{jobs.map((job) => (
<option key={job.id} value={job.id}>
{job.name}
</option>
))}
</select>
<button
onClick={() => setFilterHasWebsite(!filterHasWebsite)}
className={cn(
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
filterHasWebsite
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
)}
>
Has Website
</button>
<button
onClick={() => setFilterHasPhone(!filterHasPhone)}
className={cn(
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
filterHasPhone
? 'border-orange-200 bg-orange-50 text-orange-700'
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
)}
>
Has Phone
</button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-bottom bg-stone-50 border-stone-200">
<th className="cursor-pointer px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500 hover:text-stone-900" onClick={() => toggleSort('name')}>
<div className="flex items-center gap-2">
Business Name
<ArrowUpDown className="h-3 w-3" />
</div>
</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500">Location</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500">Category</th>
<th className="cursor-pointer px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500 hover:text-stone-900" onClick={() => toggleSort('rating')}>
<div className="flex items-center gap-2">
Rating
<ArrowUpDown className="h-3 w-3" />
</div>
</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500">Contact</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedBusinesses.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center italic text-stone-500">
No leads found matching your filters.
</td>
</tr>
) : (
paginatedBusinesses.map((business) => (
<tr key={business.id} className="group transition-colors hover:bg-stone-50">
<td className="px-6 py-4">
<div className="font-bold text-stone-900">{business.name}</div>
<div className="mt-0.5 max-w-[260px] truncate text-xs text-stone-400">{business.address}</div>
</td>
<td className="px-6 py-4 text-sm text-stone-600">{business.city || 'Unknown'}</td>
<td className="px-6 py-4">
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-800">
{business.category || 'Uncategorized'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1 text-sm font-bold text-amber-600">
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
{business.rating || 'N/A'}
<span className="text-xs font-normal text-stone-400">({business.reviewCount || 0})</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{business.website && (
<a href={business.website} target="_blank" rel="noopener" className="text-emerald-600 hover:text-emerald-700">
<Globe className="h-5 w-5" />
</a>
)}
{business.phone && (
<a href={`tel:${business.phone}`} className="text-orange-600 hover:text-orange-700">
<Phone className="h-5 w-5" />
</a>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{filteredBusinesses.length > itemsPerPage && (
<div className="flex items-center justify-between border-t border-stone-200 bg-stone-50 px-6 py-4">
<p className="text-sm text-stone-500">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{' '}
<span className="font-medium">{Math.min(currentPage * itemsPerPage, filteredBusinesses.length)}</span> of{' '}
<span className="font-medium">{filteredBusinesses.length}</span> leads
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="rounded-lg border border-stone-200 bg-white p-2 text-stone-600 disabled:opacity-50 hover:bg-stone-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="rounded-lg border border-stone-200 bg-white p-2 text-stone-600 disabled:opacity-50 hover:bg-stone-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import type { User } from '@supabase/supabase-js';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/supabase';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface LayoutProps {
user: User;
activeTab: 'setup' | 'dashboard' | 'map';
setActiveTab: (tab: 'setup' | 'dashboard' | 'map') => void;
onLogout: () => void;
children: React.ReactNode;
}
export function Layout({ user, activeTab, setActiveTab, onLogout, children }: LayoutProps) {
const userDisplayName = getUserDisplayName(user);
const userAvatarUrl = getUserAvatarUrl(user);
const navigation = [
{ id: 'setup', name: 'Setup', icon: Search },
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
{ id: 'map', name: 'Map View', icon: MapIcon },
];
return (
<div className="flex h-screen bg-stone-50 overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-stone-200 flex flex-col">
<div className="p-6 flex items-center gap-3">
<div className="bg-emerald-600 p-2 rounded-lg text-white">
<Briefcase className="h-6 w-6" />
</div>
<span className="font-bold text-xl tracking-tight text-stone-900">LeadFinder</span>
</div>
<nav className="flex-1 px-4 py-4 space-y-1">
{navigation.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all",
activeTab === item.id
? "bg-emerald-50 text-emerald-700 shadow-sm"
: "text-stone-600 hover:bg-stone-100 hover:text-stone-900"
)}
>
<item.icon className={cn("h-5 w-5", activeTab === item.id ? "text-emerald-600" : "text-stone-400")} />
{item.name}
</button>
))}
</nav>
<div className="p-4 border-t border-stone-100">
<div className="flex items-center gap-3 px-4 py-3 mb-2">
<img
src={userAvatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(userDisplayName)}`}
alt={userDisplayName}
className="h-8 w-8 rounded-full border border-stone-200"
referrerPolicy="no-referrer"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-stone-900 truncate">{userDisplayName}</p>
<p className="text-xs text-stone-500 truncate">{user.email}</p>
</div>
</div>
<button
onClick={onLogout}
className="w-full flex items-center gap-3 px-4 py-2 text-sm font-medium text-stone-600 rounded-xl hover:bg-red-50 hover:text-red-600 transition-all"
>
<LogOut className="h-5 w-5" />
Sign Out
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden relative">
{children}
</main>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { Map, AdvancedMarker, InfoWindow, Pin, useMap } from '@vis.gl/react-google-maps';
import { Globe, Loader2, MapPin, Navigation, Phone, Star } from 'lucide-react';
import { listBusinesses, listBusinessesForJob } from '../lib/database';
import type { Business } from '../types';
interface MapViewProps {
user: User;
jobId?: string | null;
}
export function MapView({ user, jobId }: MapViewProps) {
const map = useMap();
const [businesses, setBusinesses] = useState<Business[]>([]);
const [selected, setSelected] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadBusinesses = async () => {
setLoading(true);
setError(null);
try {
const nextBusinesses = jobId ? await listBusinessesForJob(user.id, jobId) : await listBusinesses(user.id);
setBusinesses(nextBusinesses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map leads.');
} finally {
setLoading(false);
}
};
void loadBusinesses();
}, [jobId, user.id]);
useEffect(() => {
if (!selected) {
return;
}
const stillExists = businesses.some((business) => business.id === selected.id);
if (!stillExists) {
setSelected(null);
}
}, [businesses, selected]);
useEffect(() => {
if (!map || businesses.length === 0) {
return;
}
const bounds = new google.maps.LatLngBounds();
let hasCoords = false;
businesses.forEach((business) => {
if (typeof business.latitude === 'number' && typeof business.longitude === 'number') {
bounds.extend({ lat: business.latitude, lng: business.longitude });
hasCoords = true;
}
});
if (!hasCoords) {
return;
}
map.fitBounds(bounds, 50);
if (businesses.length === 1) {
map.setZoom(15);
}
}, [businesses, map]);
const center = useMemo(() => {
const validCoords = businesses.filter(
(business) => typeof business.latitude === 'number' && typeof business.longitude === 'number',
);
if (validCoords.length === 0) {
return { lat: 37.42, lng: -122.08 };
}
const lat = validCoords.reduce((sum, business) => sum + (business.latitude ?? 0), 0) / validCoords.length;
const lng = validCoords.reduce((sum, business) => sum + (business.longitude ?? 0), 0) / validCoords.length;
return { lat, lng };
}, [businesses]);
if (loading) {
return (
<div className="flex flex-1 items-center justify-center bg-stone-50">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
);
}
return (
<div className="relative flex-1 bg-stone-100">
{error && (
<div className="absolute left-8 top-8 z-10 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-lg">
{error}
</div>
)}
<Map
defaultCenter={center}
defaultZoom={12}
mapId="DEMO_MAP_ID"
{...({ internalUsageAttributionIds: ['gmp_mcp_codeassist_v1_aistudio'] } as Record<string, unknown>)}
style={{ width: '100%', height: '100%' }}
gestureHandling="greedy"
>
{businesses.map((business) =>
typeof business.latitude === 'number' && typeof business.longitude === 'number' ? (
<AdvancedMarker
key={business.id}
position={{ lat: business.latitude, lng: business.longitude }}
onClick={() => setSelected(business)}
>
<Pin
background={selected?.id === business.id ? '#059669' : '#10b981'}
glyphColor="#fff"
borderColor={selected?.id === business.id ? '#064e3b' : '#065f46'}
/>
</AdvancedMarker>
) : null,
)}
{selected && typeof selected.latitude === 'number' && typeof selected.longitude === 'number' && (
<InfoWindow position={{ lat: selected.latitude, lng: selected.longitude }} onCloseClick={() => setSelected(null)}>
<div className="max-w-[280px] space-y-3 p-2">
<header>
<h3 className="text-base font-bold leading-tight text-stone-900">{selected.name}</h3>
<div className="mt-1 flex items-center gap-1 text-xs text-stone-500">
<MapPin className="h-3 w-3" />
<span className="truncate">{selected.address}</span>
</div>
</header>
<div className="flex items-center gap-3 border-y border-stone-100 py-2">
<div className="flex items-center gap-1 text-sm font-bold text-amber-600">
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
{selected.rating || 'N/A'}
<span className="text-xs font-normal text-stone-400">({selected.reviewCount || 0})</span>
</div>
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
{selected.category || 'Uncategorized'}
</span>
</div>
<div className="flex items-center gap-2">
{selected.website && (
<a
href={selected.website}
target="_blank"
rel="noopener"
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600 py-2 text-xs font-semibold text-white transition-all hover:bg-emerald-700"
>
<Globe className="h-3.5 w-3.5" />
Website
</a>
)}
{selected.phone && (
<a
href={`tel:${selected.phone}`}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-stone-900 py-2 text-xs font-semibold text-white transition-all hover:bg-stone-800"
>
<Phone className="h-3.5 w-3.5" />
Call
</a>
)}
</div>
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${selected.latitude},${selected.longitude}`}
target="_blank"
rel="noopener"
className="flex w-full items-center justify-center gap-2 pt-1 text-xs font-medium text-stone-500 hover:text-stone-900"
>
<Navigation className="h-3.5 w-3.5" />
Get Directions
</a>
</div>
</InfoWindow>
)}
</Map>
<div className="absolute bottom-8 left-8 z-10 max-w-xs rounded-2xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-sm">
<h4 className="mb-2 text-sm font-bold text-stone-900">Map Summary</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-stone-500">Total Leads on Map</span>
<span className="font-bold text-emerald-600">{businesses.length}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-stone-500">Selected Lead</span>
<span className="max-w-[120px] truncate font-bold text-stone-900">{selected ? selected.name : 'None'}</span>
</div>
{jobId && (
<div className="mt-2 border-t border-stone-200 pt-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-stone-400">Filtering by Job</p>
<p className="text-xs font-medium text-emerald-700 truncate">Active filter applied</p>
</div>
)}
</div>
</div>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { AlertCircle, CheckCircle2, History, Loader2, MapPin, Play } from 'lucide-react';
import { listSearchJobs, runSearch } from '../lib/database';
import type { SearchJob } from '../types';
interface SearchSetupProps {
user: User;
onSelectJob: (jobId: string) => void;
}
export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [radius, setRadius] = useState(5);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [jobs, setJobs] = useState<SearchJob[]>([]);
const [error, setError] = useState<string | null>(null);
const refreshJobs = useCallback(async () => {
setIsLoadingHistory(true);
try {
const nextJobs = await listSearchJobs(user.id, 10);
setJobs(nextJobs);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load search history.');
} finally {
setIsLoadingHistory(false);
}
}, [user.id]);
useEffect(() => {
void refreshJobs();
}, [refreshJobs]);
const handleRunSearch = async (e: React.FormEvent) => {
e.preventDefault();
setIsSearching(true);
setError(null);
try {
const response = await runSearch({
name: name.trim() || undefined,
location: location.trim(),
radiusKm: radius,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
});
await refreshJobs();
onSelectJob(response.job.id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed.');
} finally {
setIsSearching(false);
}
};
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-4xl space-y-8">
<header>
<h1 className="text-3xl font-bold text-stone-900">Search Setup</h1>
<p className="mt-2 text-stone-600">Submit a search job to Supabase and review the results in the dashboard or map view.</p>
</header>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="md:col-span-2">
<form onSubmit={handleRunSearch} className="space-y-6 rounded-2xl border border-stone-200 bg-white p-8 shadow-sm">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Location</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-400" />
<input
type="text"
required
placeholder="City, address, or zip"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 py-2 pl-10 pr-4 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Radius (km)</label>
<input
type="number"
min="1"
max="50"
value={radius}
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Business Type</label>
<input
type="text"
required
placeholder="e.g. coffee shop, plumber"
value={businessType}
onChange={(e) => setBusinessType(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
placeholder="e.g. organic, emergency, family-owned"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Job Name</label>
<input
type="text"
placeholder="Give this search 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 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
Searches now run through a Supabase Edge Function, so the browser no longer writes leads directly into the database.
</div>
{error && (
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={isSearching}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSearching ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Running Search...
</>
) : (
<>
<Play className="h-5 w-5" />
Run Search
</>
)}
</button>
</form>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 font-bold text-stone-900">
<History className="h-5 w-5" />
<h2>Recent Searches</h2>
</div>
{isLoadingHistory ? (
<div className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-500 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading history...
</div>
) : jobs.length === 0 ? (
<p className="text-sm italic text-stone-500">No search history yet.</p>
) : (
<div className="space-y-3">
{jobs.map((job) => (
<button
key={job.id}
onClick={() => onSelectJob(job.id)}
className="group w-full space-y-2 rounded-xl border border-stone-200 bg-white p-4 text-left shadow-sm transition-all hover:border-emerald-500 hover:shadow-md"
>
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-bold text-stone-900 group-hover:text-emerald-700">{job.name}</h3>
{job.status === 'completed' ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-emerald-500" />
) : job.status === 'running' ? (
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-emerald-500" />
) : (
<AlertCircle className="h-4 w-4 flex-shrink-0 text-red-500" />
)}
</div>
<div className="flex items-center gap-2 text-xs text-stone-500">
<MapPin className="h-3 w-3" />
<span className="truncate">{job.city} ({job.radiusKm}km)</span>
</div>
<div className="flex items-center justify-between border-t border-stone-50 pt-2">
<span className="text-xs font-medium text-stone-400">{new Date(job.createdAt).toLocaleDateString()}</span>
<span className="text-xs font-bold text-emerald-600">{job.totalResults} leads</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+196
View File
@@ -0,0 +1,196 @@
import { supabase } from './supabase';
import type { Business, SearchJob } from '../types';
type SearchJobRow = {
id: string;
user_id: string;
name: string;
city: string | null;
address: string | null;
postal_code: string | null;
radius_km: number;
business_type: string;
keywords: string | null;
status: SearchJob['status'];
total_results: number;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
};
type BusinessRow = {
id: string;
user_id: string;
external_source_id: string | null;
source: string;
name: string;
address: string | null;
city: string | null;
state_province: string | null;
postal_code: string | null;
country: string | null;
phone: string | null;
website: string | null;
rating: number | null;
review_count: number | null;
category: string | null;
hours_json: Record<string, unknown> | null;
latitude: number | null;
longitude: number | null;
general_info: string | null;
metadata_json: Record<string, unknown> | null;
first_seen_at: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
};
export type SearchJobResultLink = {
businessId: string;
searchJobId: string;
};
export type RunSearchPayload = {
name?: string;
location: string;
radiusKm: number;
businessType: string;
keywords?: string;
};
export type RunSearchResponse = {
job: SearchJob;
totalResults: number;
};
function mapSearchJob(row: SearchJobRow): SearchJob {
return {
id: row.id,
userId: row.user_id,
name: row.name,
city: row.city ?? undefined,
address: row.address ?? undefined,
postalCode: row.postal_code ?? undefined,
radiusKm: row.radius_km,
businessType: row.business_type,
keywords: row.keywords ?? undefined,
status: row.status,
totalResults: row.total_results,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapBusiness(row: BusinessRow): Business {
return {
id: row.id,
userId: row.user_id,
externalSourceId: row.external_source_id ?? undefined,
source: row.source,
name: row.name,
address: row.address ?? undefined,
city: row.city ?? undefined,
stateProvince: row.state_province ?? undefined,
postalCode: row.postal_code ?? undefined,
country: row.country ?? undefined,
phone: row.phone ?? undefined,
website: row.website ?? undefined,
rating: row.rating ?? undefined,
reviewCount: row.review_count ?? undefined,
category: row.category ?? undefined,
hoursJson: row.hours_json ? JSON.stringify(row.hours_json) : undefined,
latitude: row.latitude ?? undefined,
longitude: row.longitude ?? undefined,
generalInfo: row.general_info ?? undefined,
metadataJson: row.metadata_json ? JSON.stringify(row.metadata_json) : undefined,
firstSeenAt: row.first_seen_at ?? undefined,
lastSeenAt: row.last_seen_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function ensureData<T>(data: T | null, error: { message: string } | null, context: string): T {
if (error) {
throw new Error(`${context}: ${error.message}`);
}
if (data === null) {
throw new Error(`${context}: no data returned`);
}
return data;
}
export async function listSearchJobs(userId: string, max = 10): Promise<SearchJob[]> {
const { data, error } = await supabase
.from('search_jobs')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(max);
const rows = ensureData(data as SearchJobRow[] | null, error, 'Failed to load search jobs');
return rows.map(mapSearchJob);
}
export async function listBusinesses(userId: string): Promise<Business[]> {
const { data, error } = await supabase
.from('businesses')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
const rows = ensureData(data as BusinessRow[] | null, error, 'Failed to load businesses');
return rows.map(mapBusiness);
}
export async function listJobResultLinks(userId: string): Promise<SearchJobResultLink[]> {
const { data, error } = await supabase
.from('search_job_results')
.select('business_id, search_job_id')
.eq('user_id', userId);
const rows = ensureData(data as Array<{ business_id: string; search_job_id: string }> | null, error, 'Failed to load job links');
return rows.map((row) => ({ businessId: row.business_id, searchJobId: row.search_job_id }));
}
export async function listBusinessesForJob(userId: string, jobId: string): Promise<Business[]> {
const { data, error } = await supabase
.from('search_job_results')
.select('business:businesses(*)')
.eq('user_id', userId)
.eq('search_job_id', jobId);
const rows = ensureData(
data as Array<{ business: BusinessRow[] | null }> | null,
error,
'Failed to load businesses for job',
);
return rows.flatMap((row) => {
const business = row.business?.[0];
if (!business) {
return [];
}
return [mapBusiness(business)];
});
}
export async function runSearch(payload: RunSearchPayload): Promise<RunSearchResponse> {
const { data, error } = await supabase.functions.invoke('run-search', {
body: payload,
});
const response = ensureData(data as { job: SearchJobRow; totalResults: number } | null, error, 'Search failed');
return {
job: mapSearchJob(response.job),
totalResults: response.totalResults,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { createClient, type User } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? '';
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});
export function getUserDisplayName(user: User | null): string {
if (!user) {
return 'User';
}
const fullName = typeof user.user_metadata?.full_name === 'string' ? user.user_metadata.full_name : null;
const name = typeof user.user_metadata?.name === 'string' ? user.user_metadata.name : null;
const emailName = user.email ? user.email.split('@')[0] : null;
return fullName || name || emailName || user.email || 'User';
}
export function getUserAvatarUrl(user: User | null): string | null {
if (!user) {
return null;
}
return typeof user.user_metadata?.avatar_url === 'string' ? user.user_metadata.avatar_url : null;
}
+10
View File
@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
export type SearchJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
export interface SearchJob {
id: string;
name: string;
city?: string;
address?: string;
postalCode?: string;
radiusKm: number;
businessType: string;
keywords?: string;
status: SearchJobStatus;
totalResults: number;
startedAt?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
userId: string;
}
export interface Business {
id: string;
externalSourceId?: string;
source: string;
name: string;
address?: string;
city?: string;
stateProvince?: string;
postalCode?: string;
country?: string;
phone?: string;
website?: string;
rating?: number;
reviewCount?: number;
category?: string;
hoursJson?: string;
latitude?: number;
longitude?: number;
generalInfo?: string;
metadataJson?: string;
firstSeenAt?: string;
lastSeenAt?: string;
createdAt: string;
updatedAt: string;
userId: string;
}
export interface SearchJobResult {
id: string;
searchJobId: string;
businessId: string;
matchedKeywords?: string[];
rank?: number;
capturedAt: string;
userId: string;
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_GOOGLE_MAPS_PLATFORM_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+367
View File
@@ -0,0 +1,367 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
type SearchRequest = {
name?: string;
location: string;
radiusKm: number;
businessType: string;
keywords?: string;
};
type AddressComponent = {
longText?: string;
shortText?: string;
types?: string[];
};
type Place = {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
location?: { latitude?: number; longitude?: number };
rating?: number;
userRatingCount?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
addressComponents?: AddressComponent[];
};
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
}
function assertEnv(name: string): string {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function parseRequest(body: unknown): SearchRequest {
if (!body || typeof body !== 'object') {
throw new Error('Invalid request body');
}
const payload = body as Record<string, unknown>;
const location = typeof payload.location === 'string' ? payload.location.trim() : '';
const businessType = typeof payload.businessType === 'string' ? payload.businessType.trim() : '';
const radiusKm = typeof payload.radiusKm === 'number' ? payload.radiusKm : Number(payload.radiusKm);
const name = typeof payload.name === 'string' ? payload.name.trim() : undefined;
const keywords = typeof payload.keywords === 'string' ? payload.keywords.trim() : undefined;
if (!location || !businessType || Number.isNaN(radiusKm) || radiusKm <= 0) {
throw new Error('location, radiusKm, and businessType are required');
}
return {
name,
location,
radiusKm,
businessType,
keywords,
};
}
async function geocodeLocation(location: string, apiKey: string) {
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
url.searchParams.set('address', location);
url.searchParams.set('key', apiKey);
const response = await fetch(url);
const payload = await response.json();
if (!response.ok || payload.status !== 'OK' || !payload.results?.[0]?.geometry?.location) {
throw new Error('Unable to geocode the requested location');
}
return payload.results[0].geometry.location as { lat: number; lng: number };
}
async function searchPlaces(params: {
apiKey: string;
textQuery: string;
lat: number;
lng: number;
radiusKm: number;
}) {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': params.apiKey,
'X-Goog-FieldMask': [
'places.id',
'places.displayName',
'places.formattedAddress',
'places.location',
'places.rating',
'places.userRatingCount',
'places.websiteUri',
'places.nationalPhoneNumber',
'places.types',
'places.addressComponents',
].join(','),
},
body: JSON.stringify({
textQuery: params.textQuery,
pageSize: 20,
locationBias: {
circle: {
center: {
latitude: params.lat,
longitude: params.lng,
},
radius: Math.min(params.radiusKm * 1000, 50000),
},
},
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error?.message || 'Places search failed');
}
return (payload.places || []) as Place[];
}
function getAddressComponent(components: AddressComponent[] | undefined, type: string, useShort = false) {
if (!components) {
return null;
}
const match = components.find((component) => component.types?.includes(type));
if (!match) {
return null;
}
return useShort ? match.shortText || match.longText || null : match.longText || match.shortText || null;
}
function buildBusinessPayload(place: Place, userId: string, businessType: string) {
const city = getAddressComponent(place.addressComponents, 'locality');
const stateProvince = getAddressComponent(place.addressComponents, 'administrative_area_level_1', true);
const postalCode = getAddressComponent(place.addressComponents, 'postal_code');
const country = getAddressComponent(place.addressComponents, 'country', true);
const now = new Date().toISOString();
return {
user_id: userId,
external_source_id: place.id ?? null,
source: 'google_places',
name: place.displayName?.text || 'Unknown business',
address: place.formattedAddress ?? null,
city,
state_province: stateProvince,
postal_code: postalCode,
country,
phone: place.nationalPhoneNumber ?? null,
website: place.websiteUri ?? null,
rating: place.rating ?? null,
review_count: place.userRatingCount ?? null,
category: businessType,
latitude: place.location?.latitude ?? null,
longitude: place.location?.longitude ?? null,
metadata_json: {
google_types: place.types ?? [],
},
first_seen_at: now,
last_seen_at: now,
updated_at: now,
};
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
let jobId: string | null = null;
try {
const supabaseUrl = assertEnv('SUPABASE_URL');
const supabaseAnonKey = assertEnv('SUPABASE_ANON_KEY');
const supabaseServiceRoleKey = assertEnv('SUPABASE_SERVICE_ROLE_KEY');
const googleMapsServerKey = assertEnv('GOOGLE_MAPS_SERVER_KEY');
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return jsonResponse({ error: 'Missing Authorization header' }, 401);
}
const authClient = createClient(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
Authorization: authHeader,
},
},
});
const {
data: { user },
error: authError,
} = await authClient.auth.getUser();
if (authError || !user) {
return jsonResponse({ error: authError?.message || 'Unauthorized' }, 401);
}
const serviceClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
const payload = parseRequest(await req.json());
const now = new Date().toISOString();
const jobName = payload.name || `${payload.businessType} in ${payload.location}`;
const { data: createdJob, error: createJobError } = await serviceClient
.from('search_jobs')
.insert({
user_id: user.id,
name: jobName,
city: payload.location,
radius_km: payload.radiusKm,
business_type: payload.businessType,
keywords: payload.keywords ?? null,
status: 'running',
total_results: 0,
started_at: now,
created_at: now,
updated_at: now,
})
.select('*')
.single();
if (createJobError || !createdJob) {
throw new Error(createJobError?.message || 'Failed to create search job');
}
jobId = createdJob.id;
const geocoded = await geocodeLocation(payload.location, googleMapsServerKey);
const places = await searchPlaces({
apiKey: googleMapsServerKey,
textQuery: [payload.businessType, payload.keywords].filter(Boolean).join(' '),
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
});
const matchedKeywords = payload.keywords
? payload.keywords
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean)
: [];
let totalResults = 0;
for (const [index, place] of places.entries()) {
if (!place.id || !place.displayName?.text) {
continue;
}
const businessPayload = buildBusinessPayload(place, user.id, payload.businessType);
const { data: business, error: businessError } = await serviceClient
.from('businesses')
.upsert(businessPayload, {
onConflict: 'user_id,source,external_source_id',
})
.select('id')
.single();
if (businessError || !business) {
throw new Error(businessError?.message || 'Failed to upsert business');
}
const { error: resultError } = await serviceClient.from('search_job_results').upsert(
{
user_id: user.id,
search_job_id: jobId,
business_id: business.id,
matched_keywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
captured_at: new Date().toISOString(),
},
{
onConflict: 'search_job_id,business_id',
},
);
if (resultError) {
throw new Error(resultError.message);
}
totalResults += 1;
}
const completedAt = new Date().toISOString();
const { data: completedJob, error: completeJobError } = await serviceClient
.from('search_jobs')
.update({
total_results: totalResults,
status: 'completed',
completed_at: completedAt,
updated_at: completedAt,
})
.eq('id', jobId)
.select('*')
.single();
if (completeJobError || !completedJob) {
throw new Error(completeJobError?.message || 'Failed to finalize search job');
}
return jsonResponse({
job: completedJob,
totalResults,
});
} catch (error) {
if (jobId) {
try {
const supabaseUrl = assertEnv('SUPABASE_URL');
const supabaseServiceRoleKey = assertEnv('SUPABASE_SERVICE_ROLE_KEY');
const serviceClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
await serviceClient
.from('search_jobs')
.update({
status: 'failed',
updated_at: new Date().toISOString(),
})
.eq('id', jobId);
} catch (_updateError) {
// Ignore secondary failure while surfacing the primary error.
}
}
const message = error instanceof Error ? error.message : 'Unexpected error';
return jsonResponse({ error: message }, 500);
}
});
+165
View File
@@ -0,0 +1,165 @@
create extension if not exists pgcrypto;
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create table if not exists public.search_jobs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
name text not null,
city text,
address text,
postal_code text,
radius_km numeric not null,
business_type text not null,
keywords text,
status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
total_results integer not null default 0,
cancel_requested boolean not null default false,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.businesses (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
external_source_id text,
source text not null,
name text not null,
address text,
city text,
state_province text,
postal_code text,
country text,
phone text,
website text,
rating numeric,
review_count integer,
category text,
hours_json jsonb,
latitude double precision,
longitude double precision,
general_info text,
metadata_json jsonb,
first_seen_at timestamptz,
last_seen_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint businesses_user_source_external_source_key unique (user_id, source, external_source_id)
);
create table if not exists public.search_job_results (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
search_job_id uuid not null references public.search_jobs (id) on delete cascade,
business_id uuid not null references public.businesses (id) on delete cascade,
matched_keywords text[],
rank integer,
captured_at timestamptz not null default now(),
constraint search_job_results_job_business_key unique (search_job_id, business_id)
);
create index if not exists search_jobs_user_created_at_idx on public.search_jobs (user_id, created_at desc);
create index if not exists businesses_user_created_at_idx on public.businesses (user_id, created_at desc);
create index if not exists search_job_results_user_job_idx on public.search_job_results (user_id, search_job_id);
drop trigger if exists set_search_jobs_updated_at on public.search_jobs;
create trigger set_search_jobs_updated_at
before update on public.search_jobs
for each row
execute function public.set_updated_at();
drop trigger if exists set_businesses_updated_at on public.businesses;
create trigger set_businesses_updated_at
before update on public.businesses
for each row
execute function public.set_updated_at();
alter table public.search_jobs enable row level security;
alter table public.businesses enable row level security;
alter table public.search_job_results enable row level security;
drop policy if exists "Users can read their search jobs" on public.search_jobs;
drop policy if exists "Users can insert their search jobs" on public.search_jobs;
drop policy if exists "Users can update their search jobs" on public.search_jobs;
drop policy if exists "Users can delete their search jobs" on public.search_jobs;
drop policy if exists "Users can read their businesses" on public.businesses;
drop policy if exists "Users can insert their businesses" on public.businesses;
drop policy if exists "Users can update their businesses" on public.businesses;
drop policy if exists "Users can delete their businesses" on public.businesses;
drop policy if exists "Users can read their search job results" on public.search_job_results;
drop policy if exists "Users can insert their search job results" on public.search_job_results;
drop policy if exists "Users can update their search job results" on public.search_job_results;
drop policy if exists "Users can delete their search job results" on public.search_job_results;
create policy "Users can read their search jobs"
on public.search_jobs
for select
using (auth.uid() = user_id);
create policy "Users can insert their search jobs"
on public.search_jobs
for insert
with check (auth.uid() = user_id);
create policy "Users can update their search jobs"
on public.search_jobs
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their search jobs"
on public.search_jobs
for delete
using (auth.uid() = user_id);
create policy "Users can read their businesses"
on public.businesses
for select
using (auth.uid() = user_id);
create policy "Users can insert their businesses"
on public.businesses
for insert
with check (auth.uid() = user_id);
create policy "Users can update their businesses"
on public.businesses
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their businesses"
on public.businesses
for delete
using (auth.uid() = user_id);
create policy "Users can read their search job results"
on public.search_job_results
for select
using (auth.uid() = user_id);
create policy "Users can insert their search job results"
on public.search_job_results
for insert
with check (auth.uid() = user_id);
create policy "Users can update their search job results"
on public.search_job_results
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their search job results"
on public.search_job_results
for delete
using (auth.uid() = user_id);
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
hmr: process.env.DISABLE_HMR !== 'true',
},
});