Public Access
1
0
Files
leads4less/src/components/MapView.tsx
T
pguerrerox 1f7737e5cb feat: add workspace account page and mobile app shell
Normalize the UI with shared primitives, add a workspace-backed account surface, and improve authenticated mobile navigation, map behavior, and dashboard browsing.
2026-05-07 17:40:10 +00:00

233 lines
8.8 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
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';
import { Alert, Badge, EmptyState } from './ui';
interface MapViewProps {
user: AppUser;
jobIds?: string[] | null;
}
export function MapView({ user, jobIds }: 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);
const selectedJobCount = jobIds?.length ?? 0;
useEffect(() => {
const loadBusinesses = async () => {
setLoading(true);
setError(null);
try {
const nextBusinesses = selectedJobCount > 0 ? await listBusinessesForJobs(user.id, jobIds ?? []) : await listBusinesses(user.id);
setBusinesses(nextBusinesses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map leads.');
} finally {
setLoading(false);
}
};
void loadBusinesses();
}, [jobIds, selectedJobCount, 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>
);
}
if (businesses.length === 0) {
return (
<div className="flex flex-1 items-center justify-center bg-stone-50 p-8">
<div className="w-full max-w-lg">
<EmptyState
icon={MapPin}
title="No leads to show on the map"
description={selectedJobCount > 0
? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
: 'No saved leads are available yet. Run a research job to populate the map.'}
/>
{error ? <Alert variant="error" className="mt-4">{error}</Alert> : null}
</div>
</div>
);
}
return (
<div className="relative flex-1 bg-stone-100">
{error && (
<Alert variant="error" className="absolute left-4 right-4 top-4 z-10 shadow-lg lg:left-8 lg:right-auto lg:top-8 lg:max-w-md">{error}</Alert>
)}
<Map
defaultCenter={center}
defaultZoom={12}
style={{ width: '100%', height: '100%' }}
gestureHandling="greedy"
{...cleanMapOptions}
>
{businesses.map((business) =>
typeof business.latitude === 'number' && typeof business.longitude === 'number' ? (
<Marker
key={business.id}
position={{ lat: business.latitude, lng: business.longitude }}
onClick={() => 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,
)}
{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-semibold leading-tight text-stone-950">{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-semibold 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>
<Badge>
{selected.category || 'Uncategorized'}
</Badge>
</div>
<div className="flex items-center gap-2">
{selected.website && (
<a
href={selected.website}
target="_blank"
rel="noopener"
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
>
<Globe className="h-3.5 w-3.5" />
Website
</a>
)}
{selected.phone && (
<a
href={`tel:${selected.phone}`}
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-700 transition hover:bg-stone-50"
>
<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 inset-x-4 bottom-4 z-10 rounded-2xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-sm lg:inset-x-auto lg:bottom-8 lg:left-8 lg:max-w-xs">
<h4 className="mb-2 text-sm font-semibold text-stone-950">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-[140px] truncate font-bold text-stone-900 lg:max-w-[120px]">{selected ? selected.name : 'None'}</span>
</div>
{selectedJobCount > 0 && (
<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 Selection</p>
<p className="text-xs font-medium text-emerald-700 truncate">
{selectedJobCount === 1 ? '1 selected research job' : `${selectedJobCount} selected research jobs`}
</p>
</div>
)}
</div>
</div>
</div>
);
}