548 lines
30 KiB
React
548 lines
30 KiB
React
import React, { useState, useEffect, useRef } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import MapBg from '../assets/images/intel_map_dfw_twilight.webp';
|
|
import IntelligenceSidePanel from './dashboard/IntelligenceSidePanel';
|
|
import gsap from 'gsap';
|
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
// ── 10 Hotspots — 5 Commercial + 5 Residential, DFW Metro ──
|
|
const hotspots = [
|
|
// Commercial
|
|
{
|
|
id: 1, top: '22%', left: '58%',
|
|
address: '8333 Douglas Ave, Dallas',
|
|
type: 'Commercial',
|
|
problem: 'Roof Membrane Uplift',
|
|
gain: '+$8.5M',
|
|
weather: 'Hail Expected',
|
|
status: 'Urgent',
|
|
specs: { built: '1998', maintenance: '5 yrs since last coating' },
|
|
management: { name: 'Sterling Properties', contact: 'Sign in for contact details' },
|
|
photos: [
|
|
{ id: 'c1-1', url: '/assets/images/properties/Apartment_Complex.jpg', caption: 'Exterior View' },
|
|
{ id: 'c1-2', url: '/assets/images/properties/Office_Roof_Top_View.jpg', caption: 'Roof Aerial' },
|
|
{ id: 'c1-3', url: '/assets/images/properties/Broken_Roof_Surface.jpg', caption: 'Membrane Damage' },
|
|
]
|
|
},
|
|
{
|
|
id: 2, top: '35%', left: '38%',
|
|
address: '5600 Tennyson Pkwy, Plano',
|
|
type: 'Commercial',
|
|
problem: 'Critical Membrane Failure',
|
|
gain: '+$22.5M',
|
|
weather: 'Clear',
|
|
status: 'Monitoring',
|
|
specs: { built: '1982', maintenance: '20 yrs since roof replacement' },
|
|
management: { name: 'Goldman Group', contact: 'Sign in for contact details' },
|
|
photos: [
|
|
{ id: 'c2-1', url: '/assets/images/properties/Warehouse_Roof_Aerial.jpg', caption: 'Aerial Survey' },
|
|
{ id: 'c2-2', url: '/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg', caption: 'HVAC Systems' },
|
|
{ id: 'c2-3', url: '/assets/images/properties/Storm_Worn_Roof.jpg', caption: 'Surface Wear' },
|
|
{ id: 'c2-4', url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', caption: 'Substrate Cracking' },
|
|
]
|
|
},
|
|
{
|
|
id: 3, top: '48%', left: '72%',
|
|
address: '1900 Preston Rd, Frisco',
|
|
type: 'Commercial',
|
|
problem: 'Ponding Water Accumulation',
|
|
gain: '+$4.2M',
|
|
weather: 'Storm Watch',
|
|
status: 'Action Required',
|
|
specs: { built: '2005', maintenance: '3 yrs since drainage check' },
|
|
management: { name: 'Hillcrest Capital', contact: 'Sign in for contact details' },
|
|
photos: [
|
|
{ id: 'c3-1', url: '/assets/images/properties/Apartment_Complex.jpg', caption: 'Building Exterior' },
|
|
{ id: 'c3-2', url: '/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg', caption: 'Rooftop Overview' },
|
|
{ id: 'c3-3', url: '/assets/images/properties/Broken_Roof_Surface.jpg', caption: 'Ponding Damage' },
|
|
]
|
|
},
|
|
{
|
|
id: 4, top: '62%', left: '28%',
|
|
address: '2100 N Hwy 360, Grand Prairie',
|
|
type: 'Commercial',
|
|
problem: 'HVAC Penetration Leak',
|
|
gain: '+$6.8M',
|
|
weather: 'Windy',
|
|
status: 'Monitoring',
|
|
specs: { built: '1995', maintenance: '8 yrs since waterproofing' },
|
|
management: { name: 'Apex Industrial Partners', contact: 'Sign in for contact details' },
|
|
photos: [
|
|
{ id: 'c4-1', url: '/assets/images/properties/Warehouse_Roof_Aerial.jpg', caption: 'Facility Aerial' },
|
|
{ id: 'c4-2', url: '/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg', caption: 'HVAC Penetration' },
|
|
{ id: 'c4-3', url: '/assets/images/properties/Cracked_Storm_Shingles.jpg', caption: 'Seal Failure' },
|
|
]
|
|
},
|
|
{
|
|
id: 5, top: '15%', left: '20%',
|
|
address: '401 Commerce St, Fort Worth',
|
|
type: 'Commercial',
|
|
problem: 'Flashing Deterioration',
|
|
gain: '+$11.3M',
|
|
weather: 'Hail Expected',
|
|
status: 'Urgent',
|
|
specs: { built: '1990', maintenance: '12 yrs since seal replacement' },
|
|
management: { name: 'Tarrant Realty Holdings', contact: 'Sign in for contact details' },
|
|
photos: [
|
|
{ id: 'c5-1', url: '/assets/images/properties/Office_Roof_Top_View.jpg', caption: 'Roof Survey' },
|
|
{ id: 'c5-2', url: '/assets/images/properties/Apartment_Complex.jpg', caption: 'Building Facade' },
|
|
{ id: 'c5-3', url: '/assets/images/properties/Curled_Aging_Shingles.jpg', caption: 'Flashing Wear' },
|
|
{ id: 'c5-4', url: '/assets/images/properties/Storm_Worn_Roof.jpg', caption: 'Wind Exposure' },
|
|
]
|
|
},
|
|
// Residential
|
|
{
|
|
id: 6, top: '55%', left: '50%',
|
|
address: '4200 Beverly Dr, Highland Park',
|
|
type: 'Residential',
|
|
problem: 'Severe Hail Impact',
|
|
gain: '+$1.1M',
|
|
weather: 'Rain Soon',
|
|
status: 'Action Required',
|
|
specs: { roofSize: '62 sq', eventHistory: '8 yrs since last hail event' },
|
|
owner: { fico: '715', profile: 'Early Adopter / Long-term value' },
|
|
photos: [
|
|
{ id: 'r6-1', url: '/assets/images/properties/Luxury_Driveway_Residence.jpg', caption: 'Front Exterior' },
|
|
{ id: 'r6-2', url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', caption: 'Hail Damage' },
|
|
{ id: 'r6-3', url: '/assets/images/properties/Cracked_Storm_Shingles.jpg', caption: 'Storm Damage' },
|
|
]
|
|
},
|
|
{
|
|
id: 7, top: '72%', left: '65%',
|
|
address: '3505 Lexington Ave, University Park',
|
|
type: 'Residential',
|
|
problem: 'Asphalt Shingle Loss',
|
|
gain: '+$950K',
|
|
weather: 'Windy',
|
|
status: 'Low Priority',
|
|
specs: { roofSize: '45 sq', eventHistory: 'High Wind Zone' },
|
|
owner: { fico: '680', profile: 'First-time Buyer' },
|
|
photos: [
|
|
{ id: 'r7-1', url: '/assets/images/properties/Modern_White_Suburban_Home.jpg', caption: 'Front View' },
|
|
{ id: 'r7-2', url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', caption: 'Shingle Loss' },
|
|
{ id: 'r7-3', url: '/assets/images/properties/Roof_Inspection_Angle.jpg', caption: 'Drone Inspection' },
|
|
]
|
|
},
|
|
{
|
|
id: 8, top: '40%', left: '15%',
|
|
address: '7212 Covewood Dr, Garland',
|
|
type: 'Residential',
|
|
problem: 'Aging Composite Shingles',
|
|
gain: '+$420K',
|
|
weather: 'Clear',
|
|
status: 'Low Priority',
|
|
specs: { roofSize: '38 sq', eventHistory: 'No recent events' },
|
|
owner: { fico: '740', profile: 'Long-term Owner' },
|
|
photos: [
|
|
{ id: 'r8-1', url: '/assets/images/properties/Beige_Two_Story_House.jpg', caption: 'Street View' },
|
|
{ id: 'r8-2', url: '/assets/images/properties/Curled_Aging_Shingles.jpg', caption: 'Aging Shingles' },
|
|
]
|
|
},
|
|
{
|
|
id: 9, top: '75%', left: '42%',
|
|
address: '1520 Creekside Dr, Allen',
|
|
type: 'Residential',
|
|
problem: 'Ridge Cap Separation',
|
|
gain: '+$780K',
|
|
weather: 'Hail Expected',
|
|
status: 'Urgent',
|
|
specs: { roofSize: '55 sq', eventHistory: '2 hail events in 3 years' },
|
|
owner: { fico: '695', profile: 'Insurance-motivated' },
|
|
photos: [
|
|
{ id: 'r9-1', url: '/assets/images/properties/Brick_Front_Porch_Home.jpg', caption: 'Entryway' },
|
|
{ id: 'r9-2', url: '/assets/images/properties/Overlapping_Roof_Shingles.jpg', caption: 'Ridge Detail' },
|
|
{ id: 'r9-3', url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', caption: 'Hail Damage' },
|
|
{ id: 'r9-4', url: '/assets/images/properties/Storm_Worn_Roof.jpg', caption: 'Worn Surface' },
|
|
]
|
|
},
|
|
{
|
|
id: 10, top: '30%', left: '82%',
|
|
address: '905 W Main St, Richardson',
|
|
type: 'Residential',
|
|
problem: 'Granule Loss Pattern',
|
|
gain: '+$560K',
|
|
weather: 'Storm Watch',
|
|
status: 'Action Required',
|
|
specs: { roofSize: '42 sq', eventHistory: '5 yrs since installation' },
|
|
owner: { fico: '720', profile: 'Recently Purchased' },
|
|
photos: [
|
|
{ id: 'r10-1', url: '/assets/images/properties/Red_Shingle_Cottage.jpg', caption: 'Cottage Exterior' },
|
|
{ id: 'r10-2', url: '/assets/images/properties/Residential_Roof_Texture.jpg', caption: 'Roof Texture' },
|
|
{ id: 'r10-3', url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', caption: 'Granule Loss' },
|
|
]
|
|
},
|
|
];
|
|
|
|
const FILTERS = ['All', 'Commercial', 'Residential', 'Storm Path'];
|
|
const STORM_WEATHER = ['Hail Expected', 'Storm Watch', 'Rain Soon'];
|
|
|
|
const metrics = [
|
|
{ label: 'Total Pipeline', value: 57.1, prefix: '$', suffix: 'M', color: 'text-blue-500', stamp: '\u25CF Active', stampClass: 'permit-stamp--accent' },
|
|
{ label: 'Active Alerts', value: 7, prefix: '', suffix: '', color: 'text-amber-500', stamp: '\u25B2 Monitoring', stampClass: 'permit-stamp--accent' },
|
|
{ label: 'Urgent', value: 3, prefix: '', suffix: '', color: 'text-red-500', stamp: '\u2715 Critical', stampClass: 'permit-stamp--danger' },
|
|
{ label: 'Avg Roof Age', value: 18, prefix: '', suffix: ' yrs', color: 'text-emerald-500', stamp: '\u2713 Assessed', stampClass: 'permit-stamp--verified' },
|
|
];
|
|
|
|
const isSpotVisible = (spot, filter) => {
|
|
if (filter === 'All') return true;
|
|
if (filter === 'Commercial') return spot.type === 'Commercial';
|
|
if (filter === 'Residential') return spot.type === 'Residential';
|
|
if (filter === 'Storm Path') return STORM_WEATHER.includes(spot.weather);
|
|
return true;
|
|
};
|
|
|
|
const IntelligenceMap = () => {
|
|
const navigate = useNavigate();
|
|
const [activeSpot, setActiveSpot] = useState(null);
|
|
const [activeFilter, setActiveFilter] = useState('All');
|
|
const [hoveredSpot, setHoveredSpot] = useState(null);
|
|
const [showRescheduleDialog, setShowRescheduleDialog] = useState(false);
|
|
|
|
const sectionRef = useRef(null);
|
|
const pinRefs = useRef([]);
|
|
const metricRefs = useRef([]);
|
|
const mapGlowRef = useRef(null);
|
|
|
|
// Reschedule dialog timer
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setShowRescheduleDialog(true), 2500);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
// GSAP: Staggered pin entrance + metric counters
|
|
useEffect(() => {
|
|
const ctx = gsap.context(() => {
|
|
// Pin entrance
|
|
const pins = pinRefs.current.filter(Boolean);
|
|
pins.forEach(pin => gsap.set(pin, { opacity: 0, scale: 0, y: 20 }));
|
|
|
|
gsap.to(pins, {
|
|
opacity: 1, scale: 1, y: 0,
|
|
duration: 0.6, stagger: 0.08,
|
|
ease: 'back.out(1.7)',
|
|
scrollTrigger: {
|
|
trigger: sectionRef.current,
|
|
start: 'top 70%',
|
|
toggleActions: 'play none none none',
|
|
},
|
|
onComplete: () => {
|
|
// Clear GSAP inline styles so CSS transitions take over
|
|
pins.forEach(pin => {
|
|
if (pin) gsap.set(pin, { clearProps: 'opacity,transform' });
|
|
});
|
|
},
|
|
});
|
|
|
|
// Metric counters — proxy object approach
|
|
metricRefs.current.forEach((el) => {
|
|
if (!el) return;
|
|
const target = parseFloat(el.dataset.target);
|
|
const suffix = el.dataset.suffix || '';
|
|
const prefix = el.dataset.prefix || '';
|
|
const counter = { value: 0 };
|
|
el.innerText = prefix + '0' + suffix;
|
|
|
|
gsap.to(counter, {
|
|
value: target,
|
|
duration: 2,
|
|
ease: 'power1.out',
|
|
scrollTrigger: {
|
|
trigger: el,
|
|
start: 'top 85%',
|
|
toggleActions: 'play none none none',
|
|
},
|
|
onUpdate: () => {
|
|
const display = target % 1 !== 0
|
|
? counter.value.toFixed(1)
|
|
: Math.ceil(counter.value).toString();
|
|
el.innerText = prefix + display + suffix;
|
|
},
|
|
});
|
|
});
|
|
}, sectionRef);
|
|
|
|
return () => ctx.revert();
|
|
}, []);
|
|
|
|
// Mouse-follow glow on map
|
|
const handleMapMouseMove = (e) => {
|
|
if (!mapGlowRef.current) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
mapGlowRef.current.style.background =
|
|
`radial-gradient(350px circle at ${x}px ${y}px, rgba(6,182,212,0.08), transparent 60%)`;
|
|
};
|
|
|
|
const handleMapMouseLeave = () => {
|
|
if (mapGlowRef.current) mapGlowRef.current.style.background = 'transparent';
|
|
};
|
|
|
|
return (
|
|
<section
|
|
ref={sectionRef}
|
|
className="py-24 bg-zinc-100 dark:bg-zinc-900 border-t border-zinc-200 dark:border-white/5 relative overflow-hidden"
|
|
>
|
|
{/* Blueprint grid bg */}
|
|
<div className="absolute inset-0 blueprint-grid-fine"></div>
|
|
|
|
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
|
|
|
{/* ── Header ── */}
|
|
<div className="mb-8 text-center max-w-4xl mx-auto">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-cyan-500/10 dark:bg-cyan-400/10 border border-cyan-500/20 dark:border-cyan-400/20 mb-4">
|
|
<div className="w-1.5 h-1.5 bg-cyan-500 dark:bg-cyan-400 rounded-full animate-pulse"></div>
|
|
<span className="text-cyan-600 dark:text-cyan-400 text-xs font-mono font-bold uppercase tracking-widest">Live CRM Data Feed</span>
|
|
</div>
|
|
<h2 className="text-3xl md:text-5xl font-black text-zinc-900 dark:text-white mb-4 tracking-tight">
|
|
Hyper-Local Sales Intelligence
|
|
</h2>
|
|
<p className="text-zinc-500 dark:text-zinc-400 text-lg max-w-2xl mx-auto">
|
|
Our systems overlay live NOAA weather data with property value insights. Maximize revenue by targeting neighborhoods directly in the storm path.
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Filter Bar ── */}
|
|
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mb-8">
|
|
{FILTERS.map((filter) => (
|
|
<button
|
|
key={filter}
|
|
onClick={() => { setActiveFilter(filter); setActiveSpot(null); }}
|
|
className={`font-mono text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-md border transition-all duration-200
|
|
${activeFilter === filter
|
|
? 'bg-cyan-500/10 dark:bg-cyan-400/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/30 dark:border-cyan-400/30'
|
|
: 'bg-white dark:bg-white/5 text-zinc-500 dark:text-gray-500 border-zinc-200 dark:border-white/10 hover:text-zinc-900 dark:hover:text-white hover:border-zinc-400 dark:hover:border-white/20'
|
|
}
|
|
`}
|
|
>
|
|
{filter}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ══════════════════════════════════════════════ */}
|
|
{/* MAP CONTAINER — always dark (satellite image) */}
|
|
{/* ══════════════════════════════════════════════ */}
|
|
<div
|
|
className="relative w-full h-[400px] md:h-[550px] lg:h-[650px] bg-black rounded-2xl border border-zinc-300 dark:border-white/20 shadow-2xl overflow-hidden isolate ring-1 ring-zinc-200 dark:ring-white/10"
|
|
onMouseMove={handleMapMouseMove}
|
|
onMouseLeave={handleMapMouseLeave}
|
|
>
|
|
{/* Map Background */}
|
|
<div className="absolute inset-0 bg-[#0f172a]">
|
|
<img
|
|
src={MapBg}
|
|
alt="DFW Metroplex Map Overlay"
|
|
className="w-full h-full object-cover opacity-60 mix-blend-luminosity"
|
|
/>
|
|
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
|
|
</div>
|
|
|
|
{/* Mouse-follow glow */}
|
|
<div
|
|
ref={mapGlowRef}
|
|
className="absolute inset-0 pointer-events-none z-10 transition-[background] duration-150"
|
|
/>
|
|
|
|
{/* Region Indicator — HUD */}
|
|
<div className="absolute top-4 left-4 z-30">
|
|
<div className="bg-slate-900/80 backdrop-blur-md text-white pl-3 pr-4 py-1.5 md:pl-4 md:pr-6 md:py-2 rounded-r-lg border-l-4 border-cyan-400 shadow-[0_0_15px_rgba(0,229,255,0.2)]">
|
|
<div className="text-[9px] md:text-[10px] text-cyan-400 font-bold uppercase tracking-widest mb-0.5">Active Region</div>
|
|
<div className="font-black text-sm md:text-lg leading-none">DFW Zone 2</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Weather Widget */}
|
|
<div className="absolute top-0 right-0 w-full h-full bg-gradient-to-bl from-slate-900/80 via-transparent to-transparent pointer-events-none"></div>
|
|
<div className="absolute top-4 right-4 p-3 md:p-4 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 max-w-[180px] md:max-w-xs z-30 hidden sm:block">
|
|
<div className="flex items-center gap-3 md:gap-4">
|
|
<div className="bg-white/10 p-1.5 md:p-2 rounded-full text-white">
|
|
<span className="text-xl md:text-2xl">{'\u26C8'}</span>
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-white text-xs md:text-sm">Severe Hail Watch</div>
|
|
<div className="text-[10px] md:text-xs text-cyan-400 animate-pulse">Approaching Tarrant County</div>
|
|
<div className="text-[9px] md:text-[10px] text-gray-500 font-mono mt-1">ETA: 15 MINS</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Interactive Pins ── */}
|
|
{hotspots.map((spot, index) => {
|
|
const visible = isSpotVisible(spot, activeFilter);
|
|
const isDimmed = activeSpot && activeSpot.id !== spot.id;
|
|
const isCommercial = spot.type === 'Commercial';
|
|
|
|
return (
|
|
<div
|
|
key={spot.id}
|
|
ref={(el) => { pinRefs.current[index] = el; }}
|
|
className={`absolute group/pin z-20 cursor-pointer transition-all duration-300 ease-in-out
|
|
${!visible ? 'opacity-0 scale-0 pointer-events-none' : ''}
|
|
${visible && isDimmed ? 'opacity-30' : ''}
|
|
`}
|
|
style={{ top: spot.top, left: spot.left }}
|
|
onClick={() => setActiveSpot(activeSpot?.id === spot.id ? null : spot)}
|
|
onMouseEnter={() => setHoveredSpot(spot.id)}
|
|
onMouseLeave={() => setHoveredSpot(null)}
|
|
>
|
|
{/* Larger invisible touch target */}
|
|
<div className="absolute -inset-4 md:-inset-2" />
|
|
|
|
{/* Urgent Status Alert Pulse */}
|
|
{visible && spot.status === 'Urgent' && (
|
|
<div className="absolute -inset-8 rounded-full bg-red-500/30 animate-ping" style={{ animationDuration: '1.5s' }} />
|
|
)}
|
|
|
|
{/* Radar Ripple */}
|
|
{visible && (
|
|
<div className={`absolute ${isCommercial ? '-inset-5' : '-inset-4'} rounded-full animate-ping opacity-75 ${isCommercial ? 'bg-purple-500/20' : 'bg-cyan-400/20'
|
|
}`} />
|
|
)}
|
|
|
|
{/* Selected glow ring */}
|
|
{activeSpot?.id === spot.id && (
|
|
<div className={`absolute -inset-6 rounded-full blur-md animate-pulse ${isCommercial ? 'bg-purple-500/25' : 'bg-cyan-400/25'
|
|
}`} />
|
|
)}
|
|
|
|
{/* Pin cluster */}
|
|
<div className="relative flex flex-col items-center">
|
|
{/* Pin Head */}
|
|
<div className={`${isCommercial ? 'w-4 h-4' : 'w-3 h-3'} rounded-full border-2 border-white shadow-lg relative z-10 transition-transform duration-200 group-hover/pin:scale-125 ${isCommercial
|
|
? 'bg-purple-500 shadow-[0_0_15px_rgba(168,85,247,0.6)]'
|
|
: 'bg-cyan-400 shadow-[0_0_15px_rgba(0,229,255,0.6)]'
|
|
}`} />
|
|
|
|
{/* Pin Line */}
|
|
<div className="w-[1px] h-6 md:h-8 bg-gradient-to-b from-white to-transparent opacity-50" />
|
|
|
|
{/* Ground Shadow */}
|
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-5 h-1 bg-black/60 blur-sm rounded-[100%] translate-y-1" />
|
|
</div>
|
|
|
|
{/* Hover Tooltip */}
|
|
<div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-3 bg-slate-900/90 backdrop-blur-md text-[10px] font-mono px-3 py-2 rounded-lg border border-white/10 shadow-lg whitespace-nowrap pointer-events-none transition-all duration-200 z-30
|
|
${hoveredSpot === spot.id && activeSpot?.id !== spot.id ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1'}
|
|
`}>
|
|
<div className="font-bold text-white text-[11px]">{spot.address}</div>
|
|
<div className={`font-bold mt-0.5 ${isCommercial ? 'text-purple-400' : 'text-cyan-400'}`}>
|
|
{spot.gain}
|
|
</div>
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900/90 border-r border-b border-white/10 rotate-45 -mt-1" />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Reschedule Dialog */}
|
|
{showRescheduleDialog && (
|
|
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 md:left-6 md:translate-x-0 z-50 w-[90%] md:w-96">
|
|
<div className="bg-white/95 backdrop-blur-xl rounded-xl shadow-2xl p-5 md:p-6 border-l-4 border-red-600 animate-[slideUp_0.5s_ease-out]">
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center text-red-600 font-bold text-lg">!</div>
|
|
<h4 className="font-bold text-slate-900 text-sm uppercase tracking-wide">Schedule Conflict</h4>
|
|
</div>
|
|
<button onClick={() => setShowRescheduleDialog(false)} className="text-gray-400 hover:text-black">{'\u2715'}</button>
|
|
</div>
|
|
<p className="text-gray-600 text-sm mb-4 leading-relaxed">
|
|
<strong>Heavy rainfall expected in Plano at 2:00 PM.</strong><br />
|
|
This conflicts with the inspection at <span className="text-slate-900 font-semibold">Legacy West</span>.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => navigate('/login')} className="flex-1 bg-slate-900 text-white text-xs font-bold py-2.5 rounded hover:bg-slate-800 transition-colors uppercase tracking-wider">
|
|
Reschedule
|
|
</button>
|
|
<button onClick={() => setShowRescheduleDialog(false)} className="flex-1 bg-gray-100 text-gray-600 text-xs font-bold py-2.5 rounded hover:bg-gray-200 transition-colors uppercase tracking-wider">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Side Panel */}
|
|
{activeSpot && (
|
|
<div className="hidden md:block">
|
|
<IntelligenceSidePanel
|
|
data={activeSpot}
|
|
onClose={() => setActiveSpot(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Mobile Bottom Sheet ── */}
|
|
{activeSpot && ReactDOM.createPortal(
|
|
<div className="md:hidden fixed inset-0 z-[9999]">
|
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setActiveSpot(null)} />
|
|
<div data-lenis-prevent className="absolute bottom-0 left-0 right-0 max-h-[80vh] bg-white dark:bg-zinc-900 rounded-t-2xl border-t border-zinc-200 dark:border-white/10 overflow-y-auto overscroll-contain animate-[slideUpSheet_0.3s_ease-out] shadow-2xl touch-pan-y">
|
|
{/* Sheet header */}
|
|
<div className="sticky top-0 bg-white dark:bg-zinc-900 px-6 pt-4 pb-2 border-b border-zinc-100 dark:border-white/5 z-10">
|
|
<div className="w-10 h-1 bg-zinc-300 dark:bg-white/20 rounded-full mx-auto mb-3" />
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${activeSpot.type === 'Commercial' ? 'bg-purple-500' : 'bg-cyan-400'}`} />
|
|
<h3 className="font-bold text-zinc-900 dark:text-white text-sm">{activeSpot.address}</h3>
|
|
</div>
|
|
<button onClick={() => setActiveSpot(null)} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white p-1">{'\u2715'}</button>
|
|
</div>
|
|
</div>
|
|
<IntelligenceSidePanel
|
|
data={activeSpot}
|
|
onClose={() => setActiveSpot(null)}
|
|
variant="sheet"
|
|
/>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ── Aggregate Metrics ── */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
|
|
{metrics.map((m, i) => (
|
|
<div key={i} className="bg-white dark:bg-white/5 p-5 rounded-xl border border-zinc-200 dark:border-white/10 relative overflow-hidden hover:-translate-y-1 transition-transform duration-200">
|
|
<div className="measure-ticks measure-ticks-top absolute top-0 left-3 right-3 h-2.5"></div>
|
|
<div className="text-[10px] font-mono font-bold uppercase tracking-widest text-zinc-400 dark:text-gray-500 mb-1">
|
|
{m.label}
|
|
</div>
|
|
<div
|
|
ref={(el) => { metricRefs.current[i] = el; }}
|
|
data-target={m.value}
|
|
data-suffix={m.suffix}
|
|
data-prefix={m.prefix}
|
|
className={`text-3xl font-mono font-bold ${m.color} tracking-tight`}
|
|
>
|
|
{m.prefix}0{m.suffix}
|
|
</div>
|
|
<div className="mt-3 pt-2 border-t border-zinc-100 dark:border-white/5">
|
|
<span className={`permit-stamp ${m.stampClass}`}>{m.stamp}</span>
|
|
</div>
|
|
<div className="measure-ticks absolute bottom-0 left-3 right-3 h-2.5"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translate(-50%, 20px); }
|
|
to { opacity: 1; transform: translate(-50%, 0); }
|
|
}
|
|
@media (min-width: 768px) {
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
}
|
|
`}</style>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default IntelligenceMap;
|