feat: add Supabase-backed local lead finder app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user