1f7737e5cb
Normalize the UI with shared primitives, add a workspace-backed account surface, and improve authenticated mobile navigation, map behavior, and dashboard browsing.
233 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|