Implement Lightbox, fix photo gallery accessibility, update local assets and OG tags for Vercel deployment
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Icons } from '../landing/constants';
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
|
||||
const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
// Initialize photos from data or use empty array if not present
|
||||
const [photos, setPhotos] = useState(data.photos || []);
|
||||
// Lightbox State
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null); // null = closed, number = open at index
|
||||
|
||||
const panelRef = useRef(null);
|
||||
const closeButtonRef = useRef(null);
|
||||
const lightboxRef = useRef(null);
|
||||
|
||||
// Update photos state if data changes (e.g. switching pins)
|
||||
useEffect(() => {
|
||||
setPhotos(data.photos || []);
|
||||
setActiveTab('details'); // Reset tab when data changes
|
||||
setLightboxIndex(null); // Close lightbox if data changes
|
||||
}, [data]);
|
||||
|
||||
// Accessibility: Focus management and Escape key listener
|
||||
useEffect(() => {
|
||||
// Focus the close button when the panel opens
|
||||
if (closeButtonRef.current && !lightboxIndex) {
|
||||
closeButtonRef.current.focus();
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (lightboxIndex !== null) {
|
||||
setLightboxIndex(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, lightboxIndex]);
|
||||
|
||||
// Lightbox Keyboard Navigation
|
||||
useEffect(() => {
|
||||
if (lightboxIndex === null) return;
|
||||
|
||||
const handleLightboxKeys = (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
setLightboxIndex((prev) => (prev + 1) % photos.length);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleLightboxKeys);
|
||||
return () => document.removeEventListener('keydown', handleLightboxKeys);
|
||||
}, [lightboxIndex, photos.length]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
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"
|
||||
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>
|
||||
</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">
|
||||
<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'}`}
|
||||
onClick={() => setActiveTab('details')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'details'}
|
||||
aria-controls="panel-details"
|
||||
id="tab-details"
|
||||
>
|
||||
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'}`}
|
||||
onClick={() => setActiveTab('photos')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'photos'}
|
||||
aria-controls="panel-photos"
|
||||
id="tab-photos"
|
||||
>
|
||||
Photos ({photos.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-grow overflow-y-auto custom-scrollbar 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="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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-white/10 w-full"></div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-2 block">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>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Specs & Intel Based on Type */}
|
||||
{data.type === 'Commercial' && data.specs ? (
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border 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
|
||||
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"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') navigate('/login'); }}
|
||||
>
|
||||
{data.management.contact}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : data.type === 'Residential' && data.specs ? (
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
> ANALYSIS: {data.type === 'Commercial' ? 'High-value commercial asset at risk. Immediate inspection recommended.' : 'High probability of insurance approval due to recent storm activity.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full bg-cyan-400 hover:bg-white text-slate-900 font-bold py-3 rounded-lg transition-all duration-300 shadow-[0_0_20px_rgba(0,229,255,0.3)] hover:shadow-[0_0_30px_rgba(255,255,255,0.4)] uppercase tracking-widest text-xs focus:ring-2 focus:ring-offset-2 focus:ring-cyan-400 focus:outline-none"
|
||||
aria-label="Dispatch Sales Rep"
|
||||
>
|
||||
Dispatch Sales Rep
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PHOTOS TAB */}
|
||||
{activeTab === 'photos' && (
|
||||
<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 */}
|
||||
{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"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
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>
|
||||
|
||||
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LIGHTBOX OVERLAY */}
|
||||
{lightboxIndex !== null && photos[lightboxIndex] && (
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] bg-black/95 backdrop-blur-xl flex items-center justify-center p-4 animate-in fade-in duration-200 focus:outline-none"
|
||||
tabIndex={-1}
|
||||
ref={lightboxRef}
|
||||
role="dialog"
|
||||
aria-label="Photo Lightbox"
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={() => setLightboxIndex(null)}
|
||||
className="absolute top-4 right-4 text-cyan-400 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400"
|
||||
aria-label="Close photo view"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
{/* Navigation Left */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length); }}
|
||||
className="absolute left-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft size={32} />
|
||||
</button>
|
||||
|
||||
{/* Main Image */}
|
||||
<div className="relative max-w-5xl max-h-[85vh] w-full h-full flex flex-col items-center justify-center">
|
||||
<img
|
||||
src={photos[lightboxIndex].url}
|
||||
alt={photos[lightboxIndex].caption || "Full screen view"}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl border border-white/10"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-[-40px] text-cyan-400/50 text-xs uppercase tracking-widest font-mono">
|
||||
IMG_SEQ: {lightboxIndex + 1} / {photos.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Right */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
|
||||
className="absolute right-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight size={32} />
|
||||
</button>
|
||||
|
||||
{/* Mobile Navigation Hints */}
|
||||
<div className="md:hidden absolute bottom-8 flex space-x-8 text-cyan-400/30 text-xs pointer-events-none font-mono uppercase tracking-widest">
|
||||
<span>< PREV</span>
|
||||
<span>NEXT ></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntelligenceSidePanel;
|
||||
Reference in New Issue
Block a user