494 lines
28 KiB
React
494 lines
28 KiB
React
import React, { useEffect, useState } from 'react';
|
||
import { useMockStore } from '../data/mockStore';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { Link } from 'react-router-dom';
|
||
import {
|
||
DollarSign, Flame, Calendar, CloudRain, Wind, Droplets, Thermometer,
|
||
MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2, ShieldCheck
|
||
} from 'lucide-react';
|
||
import { SpotlightCard } from '../components/SpotlightCard';
|
||
import { config } from '../config/env';
|
||
|
||
// New Widget Imports
|
||
import { RoofConditionChart } from '../components/dashboard/RoofConditionChart';
|
||
import { TopSalesLeaderboard } from '../components/dashboard/TopSalesLeaderboard';
|
||
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
|
||
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
|
||
import { ActionCenterWidget } from '../components/dashboard/ActionCenterWidget';
|
||
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
|
||
import AnimatedNumber from '../components/AnimatedNumber';
|
||
|
||
import Loader from '../components/Loader';
|
||
|
||
const Dashboard = () => {
|
||
const { properties, meetings } = useMockStore();
|
||
const { user } = useAuth();
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
// Simulate initial data fetch
|
||
const timer = setTimeout(() => setIsLoading(false), 800);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
|
||
|
||
// --- Weather State (Lifted) ---
|
||
const [weather, setWeather] = useState(null);
|
||
const [forecast, setForecast] = useState([]);
|
||
const [weatherLoading, setWeatherLoading] = useState(true);
|
||
const [weatherError, setWeatherError] = useState(null);
|
||
const [lastUpdated, setLastUpdated] = useState(null);
|
||
|
||
const fetchWeather = async () => {
|
||
const API_KEY = config.weatherApiKey;
|
||
const CITY = "Plano,US";
|
||
try {
|
||
setWeatherLoading(true);
|
||
|
||
if (config.isDemoMode(API_KEY)) {
|
||
await new Promise(resolve => setTimeout(resolve, 800));
|
||
const mockCurrent = {
|
||
name: "Plano",
|
||
sys: { country: "US" },
|
||
main: { temp: 75, temp_max: 82, temp_min: 68, feels_like: 77, humidity: 45 },
|
||
weather: [{ id: 800, description: "Clear Sky (Demo)" }],
|
||
wind: { speed: 8 }
|
||
};
|
||
const mockForecast = Array.from({ length: 5 }).map((_, i) => ({
|
||
dt: Math.floor(Date.now() / 1000) + (i * 10800),
|
||
main: { temp: 75 + (i % 2 === 0 ? 2 : -2) },
|
||
weather: [{ id: 800 + (i * 10) }]
|
||
}));
|
||
setWeather(mockCurrent);
|
||
setForecast(mockForecast);
|
||
setLastUpdated(new Date());
|
||
setWeatherLoading(false);
|
||
setWeatherError(null);
|
||
return;
|
||
}
|
||
|
||
const currentRes = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`);
|
||
if (!currentRes.ok) throw new Error(currentRes.statusText);
|
||
const currentData = await currentRes.json();
|
||
|
||
const forecastRes = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`);
|
||
if (!forecastRes.ok) throw new Error(forecastRes.statusText);
|
||
const forecastData = await forecastRes.json();
|
||
|
||
setWeather(currentData);
|
||
setForecast(forecastData.list?.slice(0, 5) || []);
|
||
setLastUpdated(new Date());
|
||
setWeatherLoading(false);
|
||
setWeatherError(null);
|
||
} catch (err) {
|
||
console.error("Weather fetch failed", err);
|
||
setWeatherError(err.message);
|
||
setWeatherLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchWeather();
|
||
const interval = setInterval(fetchWeather, 600000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
|
||
// Clock for Plano, TX
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setCurrentTime(new Date()), 60000); // Every minute
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
// Derived Metrics & Split Schedule
|
||
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
|
||
const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1');
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
const upcomingMeetings = allMyMeetings
|
||
.filter(m => new Date(m.date) >= today)
|
||
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||
|
||
const pastMeetings = allMyMeetings
|
||
.filter(m => new Date(m.date) < today)
|
||
.sort((a, b) => new Date(b.date) - new Date(a.date)); // Most recent first
|
||
|
||
if (isLoading) return <Loader fullScreen text="Loading Dashboard..." />;
|
||
|
||
return (
|
||
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white selection:bg-blue-500/30 relative pb-20 transition-colors duration-300">
|
||
|
||
{/* Ambient Background Glows */}
|
||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||
</div>
|
||
|
||
<div className="relative z-10 p-8 max-w-7xl mx-auto space-y-8">
|
||
<header className="flex justify-between items-end pb-6">
|
||
<div>
|
||
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">Dashboard Overview</h1>
|
||
<p className="text-zinc-500 dark:text-zinc-400 font-light">
|
||
Welcome back, here's what's happening with your territory.
|
||
<span className="content-ref-x7">{'Ѕ'}{'а'}{'t'}{'у'}{'а'}{'m'} {'R'}{'а'}{'ѕ'}{'t'}{'о'}{'g'}{'і'}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Real-time Clock Widget */}
|
||
<div className="text-right">
|
||
<div className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">
|
||
{currentTime.toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour: 'numeric', minute: '2-digit', hour12: false })}
|
||
</div>
|
||
<div className="text-xs font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 mt-1">
|
||
{currentTime.toLocaleDateString('en-US', { timeZone: 'America/Chicago', weekday: 'long', month: 'long', day: 'numeric' })} • Plano, TX
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* --- ADMIN ACTION CENTER --- */}
|
||
{user?.role === 'ADMIN' && <ActionCenterWidget />}
|
||
|
||
{/* Top Metrics Grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
<MetricCard
|
||
title="Volume Closed"
|
||
value="$4.2M"
|
||
trend="+12% vs last month"
|
||
icon={DollarSign}
|
||
trendColor="text-emerald-600 dark:text-emerald-300"
|
||
bgGlow="bg-emerald-500/20"
|
||
/>
|
||
<MetricCard
|
||
title="Hot Leads"
|
||
value={hotLeads.length}
|
||
trend="Requires action"
|
||
icon={Flame}
|
||
trendColor="text-orange-600 dark:text-orange-300"
|
||
bgGlow="bg-orange-500/20"
|
||
/>
|
||
<MetricCard
|
||
title="Upcoming Meets"
|
||
value={upcomingMeetings.length}
|
||
trend="Next 7 days"
|
||
icon={Calendar}
|
||
trendColor="text-blue-600 dark:text-blue-300"
|
||
bgGlow="bg-blue-500/20"
|
||
/>
|
||
<MetricCard
|
||
title="System Status"
|
||
value="Optimal"
|
||
trend="All systems go"
|
||
icon={AlertTriangle}
|
||
trendColor="text-purple-600 dark:text-purple-300"
|
||
bgGlow="bg-purple-500/20"
|
||
/>
|
||
</div>
|
||
|
||
{/* --- ANALYTICS ROW 1: Territory Health & Risk --- */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Roof Condition - Donut */}
|
||
<div className="lg:col-span-1 h-80">
|
||
<RoofConditionChart properties={properties} />
|
||
</div>
|
||
|
||
{/* Weather Risk - Gauge */}
|
||
<div className="lg:col-span-1 h-80">
|
||
<WeatherRiskGauge properties={properties} weather={weather} />
|
||
</div>
|
||
|
||
{/* Live Weather Widget (Reused) */}
|
||
<div className="lg:col-span-1 h-80">
|
||
<RealWeatherWidget
|
||
weather={weather}
|
||
forecast={forecast}
|
||
loading={weatherLoading}
|
||
error={weatherError}
|
||
lastUpdated={lastUpdated}
|
||
onRetry={fetchWeather}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* --- ANALYTICS ROW 2: Opportunity & Intent --- */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Revenue by Neighborhood */}
|
||
<div className="lg:col-span-2 h-80">
|
||
<RevenueByNeighborhood properties={properties} />
|
||
</div>
|
||
|
||
{/* Top 6 Sales Reps Leaderboard */}
|
||
<div className="lg:col-span-1 h-80">
|
||
<TopSalesLeaderboard />
|
||
</div>
|
||
</div>
|
||
|
||
{/* --- ANALYTICS ROW 3: Golden Leads Scatter --- */}
|
||
<div className="h-96">
|
||
<GoldenLeadsScatter properties={properties} />
|
||
</div>
|
||
|
||
{/* Main Content Grid (Leads & Schedule) */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
|
||
{/* Left Col: Hot Leads and Schedule */}
|
||
<div className="lg:col-span-2 space-y-8">
|
||
|
||
{/* Hot Leads Panel */}
|
||
<SpotlightCard className="h-full">
|
||
<div className="p-8">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90">Hot Leads</h2>
|
||
<Link to="/emp/fa/maps" className="text-xs text-zinc-600 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white transition-all flex items-center bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 px-4 py-2 rounded-xl backdrop-blur-md border border-zinc-200 dark:border-white/5">
|
||
View Map <ArrowUpRight size={14} className="ml-2" />
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{hotLeads.length === 0 ? (
|
||
<div className="text-zinc-500 text-center py-12 rounded-2xl border border-dashed border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 mx-4">
|
||
No hot leads active. Time to canvas!
|
||
</div>
|
||
) : (
|
||
hotLeads.slice(0, 5).map(lead => (
|
||
<div key={lead.id} className="group flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-all border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 shadow-sm hover:shadow-md cursor-pointer">
|
||
<div className="flex items-center space-x-5">
|
||
<div className="w-10 h-10 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-zinc-600 dark:text-zinc-300 text-sm font-bold shadow-inner border border-zinc-100 dark:border-white/5">
|
||
{lead.id}
|
||
</div>
|
||
<div>
|
||
<h4 className="text-zinc-900 dark:text-white font-semibold text-sm mb-0.5">{lead.propertyData.propertyAddress.split(',')[0]}</h4>
|
||
<p className="text-zinc-500 dark:text-zinc-400 text-xs">{lead.propertyData.propertyType} • ${lead.propertyData.currentEstimatedMarketValue.toLocaleString()}</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-orange-600 dark:text-orange-300 text-[10px] font-bold uppercase tracking-wider bg-orange-100 dark:bg-orange-500/10 px-3 py-1.5 rounded-lg border border-orange-200 dark:border-orange-500/20">Hot Lead</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SpotlightCard>
|
||
</div>
|
||
|
||
{/* Right Col: Schedule */}
|
||
<div className="space-y-8">
|
||
<SpotlightCard>
|
||
<div className="p-6">
|
||
<div className="flex items-center mb-6">
|
||
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2 animate-pulse"></div>
|
||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white/90">Upcoming</h2>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{upcomingMeetings.length === 0 ? (
|
||
<p className="text-zinc-500 text-xs text-center py-8">No upcoming meetings.</p>
|
||
) : (
|
||
upcomingMeetings.map(meeting => (
|
||
<MeetingRow key={meeting.id} meeting={meeting} type="upcoming" />
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SpotlightCard>
|
||
|
||
<SpotlightCard>
|
||
<div className="p-6">
|
||
<div className="flex items-center mb-6">
|
||
<div className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-600 mr-2"></div>
|
||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white/90">History</h2>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{pastMeetings.length === 0 ? (
|
||
<p className="text-zinc-500 text-xs text-center py-8">No past meetings.</p>
|
||
) : (
|
||
pastMeetings.slice(0, 5).map(meeting => (
|
||
<MeetingRow key={meeting.id} meeting={meeting} type="past" />
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SpotlightCard>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Sub Components ---
|
||
|
||
const MeetingRow = ({ meeting, type }) => (
|
||
<div className={`flex items-center justify-between p-3 rounded-xl transition-all border ${type === 'upcoming'
|
||
? 'bg-blue-50 dark:bg-blue-500/5 border-blue-100 dark:border-blue-500/10 hover:bg-blue-100 dark:hover:bg-blue-500/10'
|
||
: 'bg-zinc-50 dark:bg-white/5 border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 opacity-70 hover:opacity-100'}`}>
|
||
|
||
<div className="flex items-center space-x-3">
|
||
<div className={`flex flex-col items-center justify-center w-10 h-10 rounded-lg shadow-sm border ${type === 'upcoming' ? 'bg-white dark:bg-blue-900/40 text-blue-600 dark:text-blue-200 border-blue-100 dark:border-blue-500/20' : 'bg-zinc-200 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 border-zinc-100 dark:border-zinc-700'}`}>
|
||
<span className="text-[8px] uppercase font-bold tracking-wider">{new Date(meeting.date).toLocaleDateString('en-US', { month: 'short' })}</span>
|
||
<span className="text-sm font-bold leading-none">{new Date(meeting.date).getDate()}</span>
|
||
</div>
|
||
<div>
|
||
<p className="text-zinc-900 dark:text-white text-xs font-bold mb-0.5">{meeting.customerName}</p>
|
||
<div className="flex items-center text-[10px] text-zinc-500 dark:text-zinc-400 space-x-2">
|
||
<span className="flex items-center"><Clock size={10} className="mr-1" />{meeting.time}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span className={`px-2 py-1 text-[8px] font-bold uppercase tracking-wider rounded-md border shadow-sm ${meeting.status === 'Completed' ? 'text-emerald-700 bg-emerald-100 border-emerald-200 dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/20' :
|
||
meeting.status === 'Converted' ? 'text-purple-700 bg-purple-100 border-purple-200 dark:text-purple-300 dark:bg-purple-500/10 dark:border-purple-500/20' :
|
||
meeting.status === 'Scheduled' ? 'text-blue-700 bg-blue-100 border-blue-200 dark:text-blue-300 dark:bg-blue-500/10 dark:border-blue-500/20' :
|
||
'text-zinc-600 bg-zinc-100 border-zinc-200 dark:text-zinc-400 dark:bg-zinc-500/10 dark:border-zinc-500/20'
|
||
}`}>
|
||
{meeting.status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) => (
|
||
<SpotlightCard className="p-6 group">
|
||
<div className="flex justify-between items-start mb-6">
|
||
<div className={`p-3 rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-inner border border-zinc-200 dark:border-white/5 group-hover:scale-110 transition-transform duration-300`}>
|
||
<Icon size={20} />
|
||
</div>
|
||
<span className={`text-[10px] uppercase font-bold tracking-wider ${trendColor} bg-zinc-100 dark:bg-white/5 px-2.5 py-1 rounded-full border border-zinc-200 dark:border-white/5 backdrop-blur-md`}>{trend}</span>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-3xl font-extrabold text-zinc-900 dark:text-white mb-2 tracking-tight drop-shadow-sm">
|
||
<AnimatedNumber value={value} duration={1.5} useLocaleString={typeof value === 'number'} />
|
||
</h3>
|
||
<p className="text-zinc-500 dark:text-zinc-500 text-xs font-semibold uppercase tracking-widest">{title}</p>
|
||
</div>
|
||
|
||
{/* Decorative Neomorphic Inner Shadow */}
|
||
<div className="absolute -bottom-10 -right-10 w-24 h-24 bg-zinc-200 dark:bg-white/5 blur-2xl rounded-full pointer-events-none group-hover:bg-zinc-300 dark:group-hover:bg-white/10 transition-colors" />
|
||
</SpotlightCard>
|
||
);
|
||
|
||
const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onRetry }) => {
|
||
|
||
// --- Icons Logic ---
|
||
const getWeatherIcon = (code) => {
|
||
if (code === 800) return <Sun size={72} className="text-amber-500 dark:text-yellow-300 drop-shadow-[0_0_15px_rgba(253,224,71,0.5)]" />;
|
||
if (code > 800) return <Cloud size={72} className="text-zinc-400 drop-shadow-[0_0_10px_rgba(255,255,255,0.2)]" />;
|
||
if (code >= 500 && code < 600) return <CloudRain size={72} className="text-blue-500 dark:text-blue-400 drop-shadow-[0_0_15px_rgba(96,165,250,0.5)]" />;
|
||
if (code >= 200 && code < 300) return <CloudRain size={72} className="text-purple-500 dark:text-purple-400 drop-shadow-[0_0_15px_rgba(168,85,247,0.5)]" />;
|
||
return <Sun size={72} className="text-amber-500 dark:text-yellow-300" />;
|
||
};
|
||
|
||
const getMiniIcon = (code) => {
|
||
if (code === 800) return <Sun size={16} className="text-amber-500 dark:text-white" />;
|
||
if (code > 800) return <Cloud size={16} className="text-zinc-500 dark:text-zinc-400" />;
|
||
if (code >= 500) return <CloudRain size={16} className="text-blue-500 dark:text-blue-400" />;
|
||
return <Sun size={16} className="text-amber-500 dark:text-white" />;
|
||
};
|
||
|
||
if (loading && !weather) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500"><Loader2 className="animate-spin mr-3" /> Syncing OWM...</div></SpotlightCard>;
|
||
|
||
if (error) return (
|
||
<SpotlightCard>
|
||
<div className="h-full flex flex-col items-center justify-center text-red-500 dark:text-red-400 p-6 text-center">
|
||
<AlertTriangle size={32} className="mb-4 opacity-80" />
|
||
<h3 className="font-bold mb-2">Weather Unavailable</h3>
|
||
<p className="text-sm text-zinc-600 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-900/50 px-4 py-2 rounded-lg border border-red-500/10 uppercase font-mono">
|
||
{error}
|
||
</p>
|
||
{(error.includes('401') || error.includes('Invalid API key')) && (
|
||
<p className="mt-4 text-xs text-zinc-500 dark:text-zinc-600 max-w-[200px]">
|
||
Please configure your <code>VITE_OPENWEATHER_API_KEY</code> in the <code>.env</code> file.
|
||
</p>
|
||
)}
|
||
<button onClick={onRetry} className="mt-4 px-4 py-2 bg-zinc-100 dark:bg-white/10 rounded-lg text-xs font-bold uppercase tracking-wider hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors">
|
||
Retry Connection
|
||
</button>
|
||
</div>
|
||
</SpotlightCard>
|
||
);
|
||
|
||
if (!weather || !weather.main) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500">Weather Unavailable</div></SpotlightCard>;
|
||
|
||
return (
|
||
<SpotlightCard className="text-zinc-900 dark:text-white overflow-hidden relative border-t-zinc-200 dark:border-t-white/20 h-full">
|
||
|
||
{/* Dynamic Weather Background Gradient - subtle */}
|
||
<div className="absolute inset-0 bg-gradient-to-b from-blue-500/10 dark:from-blue-500/5 to-transparent pointer-events-none" />
|
||
|
||
<div className="relative z-10 p-6 flex flex-col justify-between h-full">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<div className="flex items-center space-x-2 mb-2">
|
||
<div className="flex items-center space-x-2 uppercase tracking-widest text-[10px] text-zinc-500 dark:text-zinc-400 font-bold bg-zinc-100 dark:bg-white/5 py-1 px-3 rounded-full w-fit">
|
||
<MapPin size={10} />
|
||
<span>{weather.name || "Plano"}, {weather.sys?.country}</span>
|
||
</div>
|
||
<span className="flex h-2 w-2 relative">
|
||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||
</span>
|
||
</div>
|
||
|
||
<h2 className="text-5xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-zinc-800 to-zinc-400 dark:from-white dark:to-white/50 flex items-start">
|
||
<AnimatedNumber value={Math.round(weather.main.temp)} duration={1.8} />
|
||
<span className="text-2xl text-zinc-400 dark:text-white/50 mt-2 font-bold tracking-normal">°F</span>
|
||
</h2>
|
||
<p className="text-[10px] text-zinc-400 mt-1 font-mono">
|
||
Updated: {lastUpdated?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||
</p>
|
||
</div>
|
||
<div className="text-right flex flex-col items-end">
|
||
<div className="mb-2 transform hover:scale-110 transition-transform duration-500 ease-out">
|
||
{getWeatherIcon(weather.weather[0].id)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 my-2">
|
||
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
|
||
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
|
||
<Wind size={14} className="mr-2" />
|
||
<span className="text-[10px] uppercase font-bold tracking-wider">Wind</span>
|
||
</div>
|
||
<span className="text-2xl font-bold"><AnimatedNumber value={Math.round(weather.wind.speed)} duration={1.6} /> <span className="text-sm font-normal text-zinc-500">mph</span></span>
|
||
</div>
|
||
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
|
||
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
|
||
<Droplets size={14} className="mr-2" />
|
||
<span className="text-[10px] uppercase font-bold tracking-wider">Humidity</span>
|
||
</div>
|
||
<span className="text-2xl font-bold"><AnimatedNumber value={weather.main.humidity} duration={1.6} /><span className="text-sm font-normal text-zinc-500">%</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex justify-between items-center bg-zinc-100/50 dark:bg-zinc-950/30 p-4 rounded-2xl border border-zinc-200 dark:border-white/5 shadow-inner overflow-x-auto">
|
||
{forecast.map((item, index) => {
|
||
const date = new Date(item.dt * 1000);
|
||
const displayTime = date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
|
||
|
||
return (
|
||
<div key={index} className="flex flex-col items-center group cursor-default min-w-[50px]">
|
||
<span className="text-[10px] text-zinc-500 mb-2 font-medium">{displayTime}</span>
|
||
<div className="my-2 opacity-70 group-hover:opacity-100 transition-opacity">
|
||
{getMiniIcon(item.weather[0].id)}
|
||
</div>
|
||
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300"><AnimatedNumber value={Math.round(item.main.temp)} duration={1.4} delay={index * 0.15} />°</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SpotlightCard>
|
||
);
|
||
};
|
||
|
||
export default Dashboard;
|
||
|