feat: Revamped design - improved dark mode visibility, mobile UX fixes, updated trust signals
This commit is contained in:
+433
-125
@@ -1,197 +1,453 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Icons } from './landing/constants';
|
||||
import MapBg from '../assets/images/hyper_intelligence_bg_image.jpg';
|
||||
import MapBg from '../assets/images/intel_map_dfw_twilight.png';
|
||||
import IntelligenceSidePanel from './dashboard/IntelligenceSidePanel';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
// Updated with Residential & Commercial mix
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// ── 10 Hotspots — 5 Commercial + 5 Residential, DFW Metro ──
|
||||
const hotspots = [
|
||||
// Commercial
|
||||
{
|
||||
id: 1,
|
||||
top: '45%',
|
||||
left: '65%',
|
||||
address: 'Preston Royal Village',
|
||||
id: 1, top: '22%', left: '58%',
|
||||
address: '8333 Douglas Ave, Dallas',
|
||||
type: 'Commercial',
|
||||
problem: 'Roof Membrane Uplift',
|
||||
gain: '+$8.5M Value',
|
||||
gain: '+$8.5M',
|
||||
weather: 'Hail Expected',
|
||||
status: 'Urgent',
|
||||
specs: { built: '1998', maintenance: '5 years since last coating' },
|
||||
specs: { built: '1998', maintenance: '5 yrs since last coating' },
|
||||
management: { name: 'Sterling Properties', contact: 'Sign in for contact details' },
|
||||
photos: [
|
||||
{ id: 1, url: '/assets/images/properties/Warehouse_Roof_Aerial.jpg', caption: 'Warehouse Roof Aerial' },
|
||||
{ id: 2, url: '/assets/images/properties/Office_Roof_Top_View.jpg', caption: 'Office Roof Top' }
|
||||
{ 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: '28%',
|
||||
left: '42%',
|
||||
address: 'Highland Park Place',
|
||||
id: 2, top: '35%', left: '38%',
|
||||
address: '5600 Tennyson Pkwy, Plano',
|
||||
type: 'Commercial',
|
||||
problem: 'Critical Membrane Failure',
|
||||
gain: '$22.5 Million',
|
||||
gain: '+$22.5M',
|
||||
weather: 'Clear',
|
||||
status: 'Monitoring',
|
||||
specs: { built: '1982', maintenance: '20 years since last roof replacement permit' },
|
||||
specs: { built: '1982', maintenance: '20 yrs since roof replacement' },
|
||||
management: { name: 'Goldman Group', contact: 'Sign in for contact details' },
|
||||
photos: [
|
||||
{ id: 3, url: '/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg', caption: 'Rooftop HVAC Units' }
|
||||
{ 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: '55%',
|
||||
left: '25%',
|
||||
address: '4200 Beverly Dr',
|
||||
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',
|
||||
gain: '+$1.1M',
|
||||
weather: 'Rain Soon',
|
||||
status: 'Action Required',
|
||||
specs: { roofSize: '62 sq Roof', eventHistory: '8 years since last known hail event' },
|
||||
specs: { roofSize: '62 sq', eventHistory: '8 yrs since last hail event' },
|
||||
owner: { fico: '715', profile: 'Early Adopter / Long-term value' },
|
||||
photos: [
|
||||
{ id: 4, url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', caption: 'Hail Damage Close-up' },
|
||||
{ id: 5, url: '/assets/images/properties/Broken_Roof_Surface.jpg', caption: 'Broken Surface' }
|
||||
{ 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: 4,
|
||||
top: '70%',
|
||||
left: '48%',
|
||||
address: '3505 Lexington Ave',
|
||||
id: 7, top: '72%', left: '65%',
|
||||
address: '3505 Lexington Ave, University Park',
|
||||
type: 'Residential',
|
||||
problem: 'Asphalt Shingle Loss',
|
||||
gain: '+$950k Home',
|
||||
gain: '+$950K',
|
||||
weather: 'Windy',
|
||||
status: 'Low Priority',
|
||||
specs: { roofSize: '45 sq Roof', eventHistory: 'High Wind Zone' },
|
||||
specs: { roofSize: '45 sq', eventHistory: 'High Wind Zone' },
|
||||
owner: { fico: '680', profile: 'First-time Buyer' },
|
||||
photos: [
|
||||
{ id: 6, url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', caption: 'Cracked Shingles' },
|
||||
{ id: 7, url: '/assets/images/properties/Curled_Aging_Shingles.jpg', caption: 'Aging Shingles' }
|
||||
{ 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(() => {
|
||||
// Simulate incoming weather alert
|
||||
const timer = setTimeout(() => {
|
||||
setShowRescheduleDialog(true);
|
||||
}, 2500);
|
||||
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 className="py-24 bg-gray-900 border-t border-white/5 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="mb-12 text-center max-w-4xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-cyan-400/10 border border-cyan-400/20 mb-4">
|
||||
<div className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-cyan-400 text-xs font-mono font-bold uppercase tracking-widest">Live CRM Data Feed</span>
|
||||
<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-heading font-bold text-white mb-6">Hyper-Local Sales Intelligence</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
<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>
|
||||
|
||||
{/* Map Container Frame */}
|
||||
<div className="relative w-full h-[650px] bg-black rounded-2xl border border-white/20 shadow-2xl overflow-hidden group isolate ring-1 ring-white/10">
|
||||
{/* ── 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 Background - DFW Satellite Style */}
|
||||
{/* ══════════════════════════════════════════════ */}
|
||||
{/* 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]">
|
||||
{/* Aerial/Satellite View of a Grid City */}
|
||||
<img
|
||||
src={MapBg}
|
||||
alt="DFW Metroplex Map Overlay"
|
||||
className="w-full h-full object-cover opacity-60 mix-blend-luminosity hover:scale-105 transition-transform duration-[20s]"
|
||||
className="w-full h-full object-cover opacity-60 mix-blend-luminosity"
|
||||
/>
|
||||
|
||||
{/* Map Grid Overlay */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-10 bg-[length:40px_40px] border-t border-white/5"></div>
|
||||
</div>
|
||||
|
||||
{/* Region Indicator - HUD Style */}
|
||||
<div className="absolute top-6 left-6 z-30">
|
||||
<div className="bg-slate-900/80 backdrop-blur-md text-white pl-4 pr-6 py-2 rounded-r-lg border-l-4 border-cyan-400 shadow-[0_0_15px_rgba(0,229,255,0.2)] flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] text-cyan-400 font-bold uppercase tracking-widest mb-0.5">Active Region</div>
|
||||
<div className="font-heading font-bold text-lg leading-none">DFW Zone 2</div>
|
||||
</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 Overlay 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-6 right-6 p-4 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 max-w-xs z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white/10 p-2 rounded-full text-white">
|
||||
<span className="text-2xl">⛈</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Severe Hail Watch</div>
|
||||
<div className="text-xs text-cyan-400 animate-pulse">Approaching Tarrant County</div>
|
||||
<div className="text-[10px] text-gray-500 font-mono mt-1">ETA: 15 MINS</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) => (
|
||||
<div
|
||||
key={spot.id}
|
||||
className="absolute group/pin z-20 cursor-pointer"
|
||||
style={{ top: spot.top, left: spot.left }}
|
||||
onClick={() => setActiveSpot(spot)}
|
||||
>
|
||||
{/* Radar Ripple Effect */}
|
||||
<div className={`absolute -inset-4 rounded-full animate-ping opacity-75 ${spot.type === 'Commercial' ? 'bg-purple-500/20' : 'bg-cyan-400/20'}`}></div>
|
||||
{/* ── Interactive Pins ── */}
|
||||
{hotspots.map((spot, index) => {
|
||||
const visible = isSpotVisible(spot, activeFilter);
|
||||
const isDimmed = activeSpot && activeSpot.id !== spot.id;
|
||||
const isCommercial = spot.type === 'Commercial';
|
||||
|
||||
{/* Price Tag Pin */}
|
||||
<div className="relative flex flex-col items-center transform group-hover/pin:scale-110 transition-transform duration-200">
|
||||
<div className={`bg-slate-900/90 backdrop-blur text-[10px] font-mono font-bold px-2 py-1 rounded border shadow-[0_0_10px_rgba(0,0,0,0.5)] whitespace-nowrap mb-1 opacity-0 group-hover/pin:opacity-100 transition-opacity ${spot.type === 'Commercial' ? 'text-purple-400 border-purple-400/30' : 'text-cyan-400 border-cyan-400/30'}`}>
|
||||
{spot.gain}
|
||||
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" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Pin Head */}
|
||||
<div className={`w-3 h-3 rounded-full border-2 border-white shadow-lg relative z-10 ${spot.type === 'Commercial' ? '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)]'}`}></div>
|
||||
|
||||
{/* Pin Line */}
|
||||
<div className="w-[1px] h-8 bg-gradient-to-b from-white to-transparent opacity-50"></div>
|
||||
|
||||
{/* Ground Shadow */}
|
||||
<div className="absolute bottom-0 w-6 h-1.5 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>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Reschedule Dialog Overlay */}
|
||||
{/* Reschedule Dialog */}
|
||||
{showRescheduleDialog && (
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 md:left-8 md:translate-x-0 z-50 w-[90%] md:w-96">
|
||||
<div className="bg-white/95 backdrop-blur-xl rounded-xl shadow-2xl p-6 border-l-4 border-red-600 animate-[slideUp_0.5s_ease-out]">
|
||||
<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>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-sm uppercase tracking-wide">Schedule Conflict</h4>
|
||||
</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">✕</button>
|
||||
<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 />
|
||||
<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
|
||||
@@ -204,29 +460,81 @@ const IntelligenceMap = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Sidebar Panel (Glass Detail View) */}
|
||||
{/* Desktop Side Panel */}
|
||||
{activeSpot && (
|
||||
<IntelligenceSidePanel
|
||||
data={activeSpot}
|
||||
onClose={() => setActiveSpot(null)}
|
||||
/>
|
||||
<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>
|
||||
@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>
|
||||
);
|
||||
};
|
||||
|
||||
+74
-33
@@ -4,26 +4,34 @@ import { Icons } from './landing/constants';
|
||||
|
||||
const services = [
|
||||
{
|
||||
title: "Commercial and Residential Repair and Diagnostics",
|
||||
desc: "Leak detection & shingle replacement in record time. We identify micro-fractures invisible to the naked eye.",
|
||||
title: "Commercial & Residential Repair and Diagnostics",
|
||||
desc: "Leak detection & shingle replacement in record time. We identify micro-fractures invisible to the naked eye using drone-captured LiDAR scans and AI-powered thermal analysis.",
|
||||
icon: Icons.Home,
|
||||
color: "from-blue-400 to-blue-600",
|
||||
glow: "shadow-blue-500/20"
|
||||
glow: "shadow-blue-500/20",
|
||||
badge: "Licensed",
|
||||
badgeClass: "permit-stamp--verified",
|
||||
badgeIcon: "\u2713",
|
||||
stat: "2,847",
|
||||
statLabel: "Properties Inspected",
|
||||
},
|
||||
// Commercial Development Removed as per request
|
||||
{
|
||||
title: "Lead & Data Solutions",
|
||||
desc: "Advanced lead scoring and real time storm tracking alerts. Know where the damage is before the phone rings. Powered by intelligent data models and predictive algorithms, we direct your reps and marketing into high-impact zones; where probability, urgency and opportunity align.",
|
||||
desc: "Advanced lead scoring and real-time storm tracking alerts. Know where the damage is before the phone rings. We direct your reps and marketing into high-impact zones where probability, urgency and opportunity align.",
|
||||
icon: Icons.Chart,
|
||||
color: "from-purple-400 to-indigo-600",
|
||||
glow: "shadow-purple-500/20"
|
||||
glow: "shadow-purple-500/20",
|
||||
badge: "Certified",
|
||||
badgeClass: "permit-stamp--accent",
|
||||
badgeIcon: "\u25B9",
|
||||
stat: "96%",
|
||||
statLabel: "Lead Accuracy",
|
||||
}
|
||||
];
|
||||
|
||||
const Services = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Simple Tilt Logic
|
||||
const handleMouseMove = (e) => {
|
||||
const card = e.currentTarget;
|
||||
const rect = card.getBoundingClientRect();
|
||||
@@ -31,11 +39,8 @@ const Services = () => {
|
||||
const y = e.clientY - rect.top;
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
// Rotation calc (Max 5deg)
|
||||
const rotateX = ((y - centerY) / centerY) * -5;
|
||||
const rotateY = ((x - centerX) / centerX) * 5;
|
||||
|
||||
const rotateX = ((y - centerY) / centerY) * -4;
|
||||
const rotateY = ((x - centerX) / centerX) * 4;
|
||||
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
|
||||
};
|
||||
|
||||
@@ -44,52 +49,88 @@ const Services = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-white relative overflow-hidden dark:bg-black/40">
|
||||
<div className="absolute inset-0 opacity-5 bg-grid-pattern bg-[length:40px_40px]"></div>
|
||||
<section className="py-24 bg-zinc-100 dark:bg-zinc-900 relative overflow-hidden">
|
||||
<div className="absolute inset-0 blueprint-grid-fine opacity-25"></div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="max-w-5xl mx-auto px-6 relative z-10">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-slate-900 dark:text-white font-heading text-4xl font-bold">Our Core Services</h2>
|
||||
<p className="text-gray-500 mt-4 max-w-2xl mx-auto font-light">Leveraging military-grade technology for civilian roofing applications.</p>
|
||||
<span className="permit-stamp permit-stamp--accent mb-6 inline-flex">▷ Core Operations</span>
|
||||
<h2 className="text-3xl md:text-5xl font-black text-zinc-900 dark:text-white mb-4 mt-4 tracking-tight">What We Do Best</h2>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-2xl mx-auto">Military-grade technology applied to civilian roofing. Two pillars, one platform.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{services.map((service, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="group p-8 rounded-2xl border border-gray-100 dark:border-white/10 bg-white/80 dark:bg-white/5 backdrop-blur-sm shadow-xl transition-all duration-200 ease-out cursor-pointer relative overflow-hidden"
|
||||
className="group relative p-8 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-white/[0.04] hover:bg-zinc-50 dark:hover:bg-white/[0.07] backdrop-blur-sm transition-all duration-200 cursor-pointer overflow-hidden shadow-xl dark:shadow-[0_20px_60px_rgba(0,0,0,0.4)]"
|
||||
style={{ transformStyle: 'preserve-3d', transition: 'transform 0.1s ease-out' }}
|
||||
>
|
||||
{/* Gradient Glow on Hover */}
|
||||
<div className={`absolute -inset-0.5 bg-gradient-to-br ${service.color} opacity-0 group-hover:opacity-20 blur-xl transition-opacity duration-300 pointer-events-none`}></div>
|
||||
{/* Measurement ticks — top */}
|
||||
<div className="measure-ticks measure-ticks-top absolute top-0 left-4 right-4 h-3"></div>
|
||||
|
||||
{/* Gradient glow on hover */}
|
||||
<div className={`absolute -inset-0.5 bg-gradient-to-br ${service.color} opacity-0 group-hover:opacity-15 blur-xl transition-opacity duration-300 pointer-events-none`}></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<div className={`w-16 h-16 mb-6 rounded-xl flex items-center justify-center text-white bg-gradient-to-br ${service.color} shadow-lg ${service.glow} transform group-hover:scale-110 transition-transform duration-300`}>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<service.icon />
|
||||
{/* Top row: Icon + Service number */}
|
||||
<div className="flex items-start justify-between mb-6 mt-1">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-white bg-gradient-to-br ${service.color} shadow-lg ${service.glow} group-hover:scale-110 transition-transform duration-300`}>
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
<service.icon />
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] font-bold text-zinc-300 dark:text-white/15 tracking-wider">0{idx + 1}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-heading font-bold text-slate-900 dark:text-white mb-3 group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-blue-600 group-hover:to-cyan-500 dark:group-hover:from-blue-400 dark:group-hover:to-cyan-400 transition-all duration-300">
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-3 leading-snug">
|
||||
{service.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed mb-8 flex-grow">
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm leading-relaxed mb-6 flex-grow">
|
||||
{service.desc}
|
||||
</p>
|
||||
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); navigate('/login'); }}
|
||||
className="pt-6 border-t border-gray-100 dark:border-white/10 flex items-center justify-between mt-auto group-hover:border-cyan/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider group-hover:text-cyan transition-colors">Explore Solution</span>
|
||||
<div className="w-8 h-8 rounded-full border border-gray-200 dark:border-white/20 text-gray-400 flex items-center justify-center transform group-hover:rotate-45 group-hover:border-cyan group-hover:text-cyan transition-all duration-300">
|
||||
↗
|
||||
{/* Inline stat */}
|
||||
<div className="flex items-baseline gap-2 mb-6">
|
||||
<span className="font-mono text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">{service.stat}</span>
|
||||
<span className="text-xs text-zinc-500 font-medium uppercase tracking-wider">{service.statLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Footer row: Permit stamp + CTA */}
|
||||
<div className="pt-4 border-t border-zinc-100 dark:border-white/5 flex items-center justify-between mt-auto">
|
||||
<span className={`permit-stamp ${service.badgeClass}`}>{service.badgeIcon} {service.badge}</span>
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); navigate('/login'); }}
|
||||
className="flex items-center gap-2 text-xs font-bold text-zinc-500 uppercase tracking-wider group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Explore
|
||||
<div className="w-6 h-6 rounded-full border border-zinc-300 dark:border-white/10 flex items-center justify-center group-hover:border-blue-400/30 group-hover:rotate-45 transition-all duration-300 text-[10px]">
|
||||
↗
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Measurement ticks — bottom */}
|
||||
<div className="measure-ticks absolute bottom-0 left-4 right-4 h-3"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trust signals strip */}
|
||||
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-zinc-500">
|
||||
{[
|
||||
{ label: "AI-Powered Analysis", icon: "◉" },
|
||||
{ label: "15-Min Turnaround", icon: "◉" },
|
||||
{ label: "Drone-Powered Inspections", icon: "◉" },
|
||||
{ label: "Insurance-Ready Reports", icon: "◉" },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs font-mono uppercase tracking-wider">
|
||||
<span className="text-blue-500/60 text-[6px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
// Light: bg-white, Dark: bg-zinc-800
|
||||
className={`group relative ${rounded} bg-white dark:bg-zinc-800 overflow-hidden shadow-sm dark:shadow-none ${className}`}
|
||||
className={`group relative ${rounded} bg-white dark:bg-zinc-800 overflow-hidden shadow-xl dark:shadow-[0_20px_60px_rgba(0,0,0,0.4)] ${className}`}
|
||||
>
|
||||
{/*
|
||||
Rainbow Glow Layer
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Icons } from '../landing/constants';
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
|
||||
const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
|
||||
const isSheet = variant === 'sheet';
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
// Initialize photos from data or use empty array if not present
|
||||
@@ -65,31 +66,41 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute top-4 right-4 bottom-4 w-80 bg-slate-900/80 backdrop-blur-xl rounded-xl p-6 shadow-2xl border border-white/10 transform transition-transform duration-300 z-40 flex flex-col text-white"
|
||||
className={isSheet
|
||||
? 'px-6 pb-6 pt-2 flex flex-col text-zinc-900 dark:text-white'
|
||||
: 'absolute top-4 right-4 bottom-4 w-80 bg-slate-900/80 backdrop-blur-xl rounded-xl p-6 shadow-2xl border border-white/10 transform transition-transform duration-300 z-40 flex flex-col text-white'
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Property Intelligence Details"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse"></div>
|
||||
<h3 className="font-heading font-bold text-lg tracking-wide">INTEL_FEED</h3>
|
||||
{/* Header — only on desktop panel, mobile sheet provides its own */}
|
||||
{!isSheet && (
|
||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse"></div>
|
||||
<h3 className="font-heading font-bold text-lg tracking-wide">INTEL_FEED</h3>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded-full p-1"
|
||||
aria-label="Close details panel"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded-full p-1"
|
||||
aria-label="Close details panel"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10 mb-4" role="tablist">
|
||||
<div className={`flex mb-4 border-b ${isSheet ? 'border-zinc-200 dark:border-white/10' : 'border-white/10'}`} role="tablist">
|
||||
<button
|
||||
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'details' ? 'border-cyan-400 text-cyan-400' : 'border-transparent text-gray-400 hover:text-white'}`}
|
||||
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'details'
|
||||
? 'border-cyan-400 text-cyan-400'
|
||||
: isSheet
|
||||
? 'border-transparent text-zinc-400 dark:text-gray-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab('details')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'details'}
|
||||
@@ -99,7 +110,12 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'photos' ? 'border-cyan-400 text-cyan-400' : 'border-transparent text-gray-400 hover:text-white'}`}
|
||||
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'photos'
|
||||
? 'border-cyan-400 text-cyan-400'
|
||||
: isSheet
|
||||
? 'border-transparent text-zinc-400 dark:text-gray-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab('photos')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'photos'}
|
||||
@@ -110,31 +126,64 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-grow overflow-y-auto custom-scrollbar relative">
|
||||
{/* Status / Weather Quick Bar */}
|
||||
<div className={`flex items-center gap-2 mb-4 flex-wrap ${isSheet ? '' : ''}`}>
|
||||
{data.status && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider
|
||||
${data.status === 'Urgent'
|
||||
? 'bg-red-500/15 text-red-400 border border-red-500/25'
|
||||
: data.status === 'Action Required'
|
||||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/25'
|
||||
: data.status === 'Monitoring'
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/25'
|
||||
: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/25'
|
||||
}
|
||||
`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
data.status === 'Urgent' ? 'bg-red-400 animate-pulse' :
|
||||
data.status === 'Action Required' ? 'bg-amber-400' :
|
||||
data.status === 'Monitoring' ? 'bg-blue-400' : 'bg-emerald-400'
|
||||
}`} />
|
||||
{data.status}
|
||||
</span>
|
||||
)}
|
||||
{data.weather && data.weather !== 'Clear' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{'\u26C8'} {data.weather}
|
||||
</span>
|
||||
)}
|
||||
{data.weather === 'Clear' && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${isSheet ? 'bg-zinc-100 dark:bg-white/5 text-zinc-400 dark:text-gray-500 border border-zinc-200 dark:border-white/10' : 'bg-white/5 text-gray-500 border border-white/10'}`}>
|
||||
{'\u2600'} Clear
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area — data-lenis-prevent stops Lenis from hijacking wheel/touch scroll */}
|
||||
<div data-lenis-prevent className="flex-grow overflow-y-auto overscroll-contain relative">
|
||||
|
||||
{/* DETAILS TAB */}
|
||||
{activeTab === 'details' && (
|
||||
<div role="tabpanel" id="panel-details" aria-labelledby="tab-details" className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div className="p-4 bg-cyan-400/5 rounded-lg border border-cyan-400/20 relative overflow-hidden group/card hover:bg-cyan-400/10 transition-colors">
|
||||
<div className={`p-4 rounded-lg border relative overflow-hidden group/card transition-colors ${isSheet ? 'bg-cyan-500/5 dark:bg-cyan-400/5 border-cyan-500/20 dark:border-cyan-400/20 hover:bg-cyan-500/10 dark:hover:bg-cyan-400/10' : 'bg-cyan-400/5 border-cyan-400/20 hover:bg-cyan-400/10'}`}>
|
||||
<div className="absolute top-0 right-0 p-1 opacity-20 group-hover/card:opacity-40 transition-opacity">
|
||||
<Icons.Chart />
|
||||
</div>
|
||||
<label className="text-[10px] text-cyan-400 font-bold uppercase tracking-widest mb-1 block">Projected Value</label>
|
||||
<div className="text-3xl font-heading font-bold text-white">{data.gain}</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-cyan-600 dark:text-cyan-400' : 'text-cyan-400'}`}>Projected Value</label>
|
||||
<div className={`text-3xl font-heading font-bold ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.gain}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Location Target</label>
|
||||
<div className="text-white font-bold text-lg leading-tight mb-1">{data.address}</div>
|
||||
<div className="text-xs text-gray-400">Dallas-Fort Worth Metroplex • {data.type} Zone</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Location Target</label>
|
||||
<div className={`font-bold text-lg leading-tight mb-1 ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.address}</div>
|
||||
<div className={`text-xs ${isSheet ? 'text-zinc-500 dark:text-gray-400' : 'text-gray-400'}`}>Dallas-Fort Worth Metroplex • {data.type} Zone</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-white/10 w-full"></div>
|
||||
<div className={`h-px w-full ${isSheet ? 'bg-zinc-200 dark:bg-white/10' : 'bg-white/10'}`}></div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-2 block">Detected Issue</label>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-2 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Detected Issue</label>
|
||||
<div className={`flex items-center gap-2 font-bold ${data.problem.includes('Severe') ? 'text-red-500' : 'text-yellow-400'}`}>
|
||||
<span className="animate-pulse">⚠</span> {data.problem}
|
||||
</div>
|
||||
@@ -145,17 +194,17 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Built</label>
|
||||
<div className="text-sm text-white font-medium">{data.specs.built}</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Built</label>
|
||||
<div className={`text-sm font-medium ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.specs.built}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Maintenance</label>
|
||||
<div className="text-sm text-white font-medium">{data.specs.maintenance}</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Maintenance</label>
|
||||
<div className={`text-sm font-medium ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.specs.maintenance}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
||||
<div className={`p-3 rounded-lg border ${isSheet ? 'bg-zinc-100 dark:bg-slate-800/50 border-zinc-200 dark:border-white/5' : 'bg-slate-800/50 border-white/5'}`}>
|
||||
<label className="text-[10px] text-purple-400 font-bold uppercase tracking-widest mb-1 block">Management</label>
|
||||
<div className="text-sm text-white font-bold">{data.management.name}</div>
|
||||
<div className={`text-sm font-bold ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.management.name}</div>
|
||||
<div
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-xs text-blue-400 cursor-pointer hover:underline mt-1 focus:outline-none focus:ring-1 focus:ring-blue-400 rounded"
|
||||
@@ -171,30 +220,30 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Roof Specs</label>
|
||||
<div className="text-sm text-white font-medium">{data.specs.roofSize}</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Roof Specs</label>
|
||||
<div className={`text-sm font-medium ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.specs.roofSize}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Risk History</label>
|
||||
<div className="text-sm text-white font-medium">{data.specs.eventHistory}</div>
|
||||
<label className={`text-[10px] font-bold uppercase tracking-widest mb-1 block ${isSheet ? 'text-zinc-400 dark:text-gray-500' : 'text-gray-500'}`}>Risk History</label>
|
||||
<div className={`text-sm font-medium ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.specs.eventHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
||||
<div className={`p-3 rounded-lg border ${isSheet ? 'bg-zinc-100 dark:bg-slate-800/50 border-zinc-200 dark:border-white/5' : 'bg-slate-800/50 border-white/5'}`}>
|
||||
<label className="text-[10px] text-emerald-400 font-bold uppercase tracking-widest mb-1 block">Owner Intelligence</label>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-0.5">FICO Score</div>
|
||||
<div className="text-sm text-white font-bold">{data.owner.fico}</div>
|
||||
<div className={`text-xs mb-0.5 ${isSheet ? 'text-zinc-500 dark:text-gray-400' : 'text-gray-400'}`}>FICO Score</div>
|
||||
<div className={`text-sm font-bold ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.owner.fico}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-400 mb-0.5">Profile</div>
|
||||
<div className="text-xs text-white font-medium">{data.owner.profile}</div>
|
||||
<div className={`text-xs mb-0.5 ${isSheet ? 'text-zinc-500 dark:text-gray-400' : 'text-gray-400'}`}>Profile</div>
|
||||
<div className={`text-xs font-medium ${isSheet ? 'text-zinc-900 dark:text-white' : 'text-white'}`}>{data.owner.profile}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
<div className={`text-xs italic ${isSheet ? 'text-zinc-500 dark:text-gray-400' : 'text-gray-400'}`}>
|
||||
> ANALYSIS: {data.type === 'Commercial' ? 'High-value commercial asset at risk. Immediate inspection recommended.' : 'High probability of insurance approval due to recent storm activity.'}
|
||||
</div>
|
||||
)}
|
||||
@@ -214,11 +263,11 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
<div role="tabpanel" id="panel-photos" aria-labelledby="tab-photos" className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300 h-full flex flex-col">
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pb-20"> {/* pb-20 for bottom fixed button space if needed, though button is inline here */}
|
||||
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||
{photos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden group border border-white/10 hover:border-cyan-400/50 transition-colors cursor-pointer"
|
||||
className={`relative aspect-square rounded-lg overflow-hidden group border transition-colors cursor-pointer ${isSheet ? 'border-zinc-200 dark:border-white/10 hover:border-cyan-500/50 dark:hover:border-cyan-400/50' : 'border-white/10 hover:border-cyan-400/50'}`}
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
>
|
||||
<img
|
||||
@@ -226,18 +275,19 @@ const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
alt={photo.caption || 'Property view'}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<span className="text-cyan-400 text-xs font-bold uppercase tracking-widest border border-cyan-400/50 px-2 py-1 rounded-full backdrop-blur-sm shadow-[0_0_10px_rgba(34,211,238,0.3)]">View</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-end p-2">
|
||||
<span className="text-cyan-400 text-xs font-bold uppercase tracking-widest border border-cyan-400/50 px-2 py-1 rounded-full backdrop-blur-sm shadow-[0_0_10px_rgba(34,211,238,0.3)] mb-1.5">View</span>
|
||||
{photo.caption && (
|
||||
<span className="text-white/80 text-[9px] text-center leading-tight font-mono">{photo.caption}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Upload Placeholders if empty */}
|
||||
{photos.length === 0 && (
|
||||
<div className="col-span-2 py-8 text-center border-2 border-dashed border-white/10 rounded-lg bg-white/5">
|
||||
<p className="text-gray-400 text-xs mb-2">No photos available</p>
|
||||
<div className={`col-span-2 py-8 text-center border-2 border-dashed rounded-lg ${isSheet ? 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5' : 'border-white/10 bg-white/5'}`}>
|
||||
<p className={`text-xs mb-2 ${isSheet ? 'text-zinc-400 dark:text-gray-400' : 'text-gray-400'}`}>No photos available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useRef, useMemo } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* ParticleField — Internal R3F component
|
||||
* Renders ~800 small white dots with gentle organic drift and mouse parallax.
|
||||
* Uses Points geometry with BufferAttribute for maximum performance.
|
||||
* Aesthetic: "aerospace night sky" — calm, precise, not chaotic.
|
||||
*/
|
||||
function ParticleField({ count = 800 }) {
|
||||
const points = useRef();
|
||||
const { pointer } = useThree();
|
||||
|
||||
// Generate initial positions + per-particle phase seeds
|
||||
const { geometry, basePositions, seeds } = useMemo(() => {
|
||||
const positions = new Float32Array(count * 3);
|
||||
const seedArr = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
// Wide rectangular spread — matches hero viewport proportions
|
||||
positions[i3] = (Math.random() - 0.5) * 26; // x: wide
|
||||
positions[i3 + 1] = (Math.random() - 0.5) * 16; // y: tall
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 10; // z: depth layers
|
||||
// Random phase offsets for organic non-uniform motion
|
||||
seedArr[i3] = Math.random() * Math.PI * 2;
|
||||
seedArr[i3 + 1] = Math.random() * Math.PI * 2;
|
||||
seedArr[i3 + 2] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const posAttr = new THREE.BufferAttribute(positions, 3);
|
||||
posAttr.setUsage(THREE.DynamicDrawUsage); // Optimized for frequent updates
|
||||
geo.setAttribute('position', posAttr);
|
||||
|
||||
return {
|
||||
geometry: geo,
|
||||
basePositions: new Float32Array(positions), // Immutable copy
|
||||
seeds: seedArr,
|
||||
};
|
||||
}, [count]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!points.current) return;
|
||||
|
||||
const time = state.clock.elapsedTime * 0.25; // Very slow time factor
|
||||
const posArray = points.current.geometry.attributes.position.array;
|
||||
|
||||
// Per-particle gentle drift
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
posArray[i3] = basePositions[i3] + Math.sin(time + seeds[i3]) * 0.12;
|
||||
posArray[i3 + 1] = basePositions[i3 + 1] + Math.cos(time * 0.7 + seeds[i3 + 1]) * 0.10;
|
||||
posArray[i3 + 2] = basePositions[i3 + 2] + Math.sin(time * 0.4 + seeds[i3 + 2]) * 0.06;
|
||||
}
|
||||
points.current.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Mouse parallax — gentle rotation of the entire field
|
||||
points.current.rotation.y = THREE.MathUtils.lerp(
|
||||
points.current.rotation.y,
|
||||
pointer.x * 0.06,
|
||||
0.012
|
||||
);
|
||||
points.current.rotation.x = THREE.MathUtils.lerp(
|
||||
points.current.rotation.x,
|
||||
-pointer.y * 0.04,
|
||||
0.012
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={points} geometry={geometry}>
|
||||
<pointsMaterial
|
||||
size={0.025}
|
||||
color="#ffffff"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
sizeAttenuation
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HeroParticles — Exported wrapper
|
||||
* Transparent R3F Canvas overlaying the hero section.
|
||||
* pointer-events: none so clicks pass through to CTAs below.
|
||||
*/
|
||||
export default function HeroParticles() {
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 12], fov: 55 }}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: 'default',
|
||||
stencil: false,
|
||||
depth: false,
|
||||
}}
|
||||
dpr={[1, 1.5]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<ParticleField />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user