feat: Release CRM V2 (Clean History)

Squashed commits for V2 release including Dark Mode, Chatbot, and Landing Page enhancements.
This commit is contained in:
Satyam
2026-02-01 03:49:20 +05:30
commit 8a749d3041
42 changed files with 12518 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { useMockStore } from '../data/mockStore';
import { useAuth } from '../context/AuthContext';
import { Calendar, User, Clock, MapPin, MoreHorizontal } from 'lucide-react';
const AdminSchedule = () => {
const { meetings } = useMockStore();
// In a real app we would have a function to update meetings here
// Group meetings by agent for display
const meetingsByAgent = meetings.reduce((acc, meeting) => {
const agentId = meeting.agentId;
if (!acc[agentId]) acc[agentId] = [];
acc[agentId].push(meeting);
return acc;
}, {});
return (
<div className="p-8 max-w-7xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Team Schedule</h1>
<p className="text-slate-400">Manage field agent appointments</p>
</header>
<div className="bg-slate-900 border border-slate-800 rounded-3xl shadow-xl overflow-hidden">
<div className="p-6 border-b border-slate-800">
<h2 className="text-xl font-bold text-white flex items-center">
<Calendar size={20} className="text-blue-500 mr-2" />
All Active Appointments
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-400">
<thead className="bg-slate-950 text-slate-200 uppercase font-bold text-xs">
<tr>
<th className="px-6 py-4">Agent</th>
<th className="px-6 py-4">Customer</th>
<th className="px-6 py-4">Property</th>
<th className="px-6 py-4">Date & Time</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{meetings.map((meeting) => (
<tr key={meeting.id} className="hover:bg-slate-800/50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-slate-300 font-bold text-xs">
{meeting.agentId}
</div>
<span className="font-medium text-slate-200">Agent {meeting.agentId}</span>
</div>
</td>
<td className="px-6 py-4 text-slate-300">{meeting.customerName}</td>
<td className="px-6 py-4">{meeting.propertyId}</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-slate-200">{meeting.date}</span>
<span className="text-xs">{meeting.time}</span>
</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs font-semibold rounded ${meeting.status === 'Completed' ? 'bg-green-500/20 text-green-400' :
meeting.status === 'Scheduled' ? 'bg-blue-500/20 text-blue-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>
{meeting.status}
</span>
</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-400 hover:text-blue-300 text-xs font-bold mr-3">Reschedule</button>
<button className="text-slate-600 hover:text-slate-400">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default AdminSchedule;
+461
View File
@@ -0,0 +1,461 @@
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
} from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
import { config } from '../config/env';
const Dashboard = () => {
const { properties, meetings } = useMockStore();
const { user } = useAuth();
// 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');
// Filter meetings for current user (or fallback to e1 for dev)
const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1');
// Split into Upcoming vs Past
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
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.</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>
{/* 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>
{/* Main Content Grid */}
<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>
{/* Split Schedule System */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Upcoming Meetings */}
<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>
{/* Past / Completed Meetings */}
<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>
{/* Right Col: Weather Widget */}
<div className="space-y-8">
<RealWeatherWidget />
</div>
</div>
</div>
</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">{value}</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 = () => {
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const API_KEY = config.weatherApiKey;
const CITY = "Plano,US";
const fetchWeather = async () => {
try {
setLoading(true);
// --- DEMO MODE CHECK ---
if (config.isDemoMode(API_KEY)) {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 800));
// MOCK DATA
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 }
};
// Mock Forecast (Next 15 hours)
const mockForecast = Array.from({ length: 5 }).map((_, i) => ({
dt: Math.floor(Date.now() / 1000) + (i * 10800), // +3 hours each
main: { temp: 75 + (i % 2 === 0 ? 2 : -2) },
weather: [{ id: 800 + (i * 10) }] // Varying sunny/cloudy codes
}));
setWeather(mockCurrent);
setForecast(mockForecast);
setLastUpdated(new Date());
setLoading(false);
setError(null);
return; // Exit early
}
// --- REAL API FETCH ---
// Fetch Current Weather
const currentRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`
);
if (!currentRes.ok) {
const errData = await currentRes.json().catch(() => ({ message: currentRes.statusText }));
throw new Error(errData.message || `API Error: ${currentRes.status}`);
}
const currentData = await currentRes.json();
// Fetch 5 Day / 3 Hour Forecast
const forecastRes = await fetch(
`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`
);
if (!forecastRes.ok) {
const errData = await forecastRes.json().catch(() => ({ message: forecastRes.statusText }));
throw new Error(errData.message || `Forecast Error: ${forecastRes.status}`);
}
const forecastData = await forecastRes.json();
setWeather(currentData);
setForecast(forecastData.list?.slice(0, 5) || []);
setLastUpdated(new Date());
setLoading(false);
setError(null);
} catch (err) {
console.error("Weather fetch failed", err);
setError(err.message);
setLoading(false);
}
};
useEffect(() => {
fetchWeather();
// Refresh every 10 minutes
const interval = setInterval(fetchWeather, 600000);
return () => clearInterval(interval);
}, []);
const getWeatherIcon = (code) => {
// OWM codes: https://openweathermap.org/weather-conditions
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-80 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-80 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={fetchWeather} 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-80 flex items-center justify-center text-zinc-500">Weather Unavailable</div></SpotlightCard>;
return (
// Added text-zinc-900 to ensure text is visible in light mode
<SpotlightCard className="text-zinc-900 dark:text-white overflow-hidden relative border-t-zinc-200 dark:border-t-white/20">
{/* 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-8">
<div className="flex justify-between items-start mb-8">
<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-6xl 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">
{Math.round(weather.main.temp)}
<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>
<p className="font-semibold text-zinc-600 dark:text-zinc-200 text-lg capitalize">{weather.weather[0].description}</p>
<div className="text-xs text-zinc-400 flex flex-col items-end mt-1 font-mono">
<span>H: {Math.round(weather.main.temp_max)}°F L: {Math.round(weather.main.temp_min)}°F</span>
<span>Feels: {Math.round(weather.main.feels_like)}°F</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-10">
<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">{Math.round(weather.wind.speed)} <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">{weather.main.humidity}<span className="text-sm font-normal text-zinc-500">%</span></span>
</div>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-4 ml-1">Forecast (3-Hour Steps)</p>
<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">{Math.round(item.main.temp)}°</span>
</div>
)
})}
</div>
</div>
</div>
</SpotlightCard>
);
};
export default Dashboard;
+307
View File
@@ -0,0 +1,307 @@
import React, { useEffect, useRef } from 'react';
import Chatbot from '../components/Chatbot';
import { useAuth } from '../context/AuthContext';
import { ArrowRight, CheckCircle2, Home, Star, ShieldCheck, Clock, Award, TrendingDown, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { SpotlightCard } from '../components/SpotlightCard';
import { ComparisonSlider } from '../components/ComparisonSlider';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const Landing = () => {
const { user } = useAuth();
const heroRef = useRef(null);
const textRef = useRef(null);
const sectionsRef = useRef([]);
useEffect(() => {
// Hero Animation
gsap.fromTo(textRef.current.children,
{ y: 50, opacity: 0 },
{ y: 0, opacity: 1, duration: 1, stagger: 0.2, ease: "power3.out" }
);
// Scroll Animations
sectionsRef.current.forEach((el) => {
gsap.fromTo(el,
{ y: 50, opacity: 0 },
{
y: 0, opacity: 1, duration: 0.8, ease: "power2.out",
scrollTrigger: {
trigger: el,
start: "top 80%",
}
}
);
});
}, []);
const addToRefs = (el) => {
if (el && !sectionsRef.current.includes(el)) {
sectionsRef.current.push(el);
}
};
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] text-zinc-900 dark:text-white transition-colors duration-300 overflow-x-hidden selection:bg-emerald-500/30">
{/* DITHER EFFECT - Fixed Overlay */}
<div className="fixed inset-0 pointer-events-none z-50 opacity-[0.03] dark:opacity-[0.05]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=%220 0 200 200%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cfilter id=%22noiseFilter%22%3E%3CfeTurbulence type=%22fractalNoise%22 baseFrequency=%220.65%22 numOctaves=%223%22 stitchTiles=%22stitch%22/%3E%3C/filter%3E%3Crect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23noiseFilter)%22/%3E%3C/svg%3E")' }}></div>
{/* Nav */}
<nav className="border-b border-zinc-200 dark:border-white/5 backdrop-blur-xl sticky top-0 z-40 bg-white/70 dark:bg-black/50">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-black dark:bg-white rounded-lg flex items-center justify-center shadow-lg">
<Home size={16} className="text-white dark:text-black" />
</div>
<span className="text-xl font-bold tracking-tight">LynkedUpPro</span>
</div>
<div className="flex items-center space-x-4">
{!user ? (
<Link to="/login" className="px-5 py-2 rounded-full text-xs font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900 border hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors">
Sign In
</Link>
) : (
<Link to={user.role === 'customer' ? '/' : '/emp/fa/dashboard'} className="px-5 py-2 rounded-full text-xs font-bold uppercase tracking-wider bg-black dark:bg-white text-white dark:text-black hover:opacity-80 transition-colors">
Dashboard
</Link>
)}
</div>
</div>
</nav>
{/* HERO SECTION */}
<div className="relative pt-32 pb-40 overflow-hidden" ref={heroRef}>
{/* Background Image with Overlay */}
<div className="absolute inset-0 z-0">
<img src="/src/assets/images/hero.png" alt="Modern Roof" className="w-full h-full object-cover opacity-20 dark:opacity-40" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-zinc-50/80 to-zinc-50 dark:via-[#050505]/80 dark:to-[#050505]"></div>
</div>
<div className="max-w-7xl mx-auto px-6 relative z-10 text-center" ref={textRef}>
<div className="inline-flex items-center px-4 py-1.5 rounded-full bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-500/20 text-[10px] font-bold tracking-widest uppercase mb-8 backdrop-blur-sm shadow-sm text-blue-800 dark:text-blue-300">
<span className="mr-2 text-base">🇺🇸</span>
Proudly American Made by Americans, For Americans
</div>
<h1 className="text-6xl md:text-9xl font-black tracking-tighter mb-6 leading-[0.9]">
ROOFING <br />
<span className="text-transparent bg-clip-text bg-gradient-to-b from-blue-700 to-red-600 dark:from-blue-400 dark:to-red-400">REVOLUTIONIZED</span>
</h1>
<p className="text-xl md:text-2xl text-zinc-500 dark:text-zinc-400 max-w-2xl mx-auto mb-12 font-medium">
Automated drone inspections. Instant AI estimates.
<br className="hidden md:block" />
Restoring your home's integrity with 20+ years of precision.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
<Link to="/login" className="px-8 py-4 rounded-full bg-blue-700 hover:bg-blue-800 text-white font-bold transition-all flex items-center shadow-lg shadow-blue-600/20 hover:scale-105 ring-4 ring-blue-500/10 dark:ring-blue-400/10">
Get Free Estimate
<ArrowRight size={18} className="ml-2" />
</Link>
<button className="px-8 py-4 rounded-full bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 hover:bg-zinc-200 dark:hover:bg-white/10 font-semibold transition-colors">
See The Process
</button>
</div>
{/* Patriotic Discount Banner */}
<div className="max-w-md mx-auto bg-white/50 dark:bg-zinc-900/50 backdrop-blur-md rounded-2xl border border-red-100 dark:border-red-900/30 p-4 flex items-center justify-center space-x-4 shadow-sm">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center text-red-600 dark:text-red-400">
<Award size={20} />
</div>
<div className="text-left">
<h3 className="font-bold text-sm text-zinc-900 dark:text-white">Active Duty, Veterans & Seniors</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Get an exclusive <span className="text-red-600 dark:text-red-400 font-bold">15% Discount</span> on all services.</p>
</div>
</div>
{/* Satisfaction Badges */}
<div className="mt-12 flex items-center justify-center gap-8 opacity-70 grayscale hover:grayscale-0 transition-all duration-500">
<div className="flex items-center space-x-2">
<div className="flex -space-x-2">
<div className="w-8 h-8 rounded-full bg-gray-300 border-2 border-white dark:border-black"></div>
<div className="w-8 h-8 rounded-full bg-gray-400 border-2 border-white dark:border-black"></div>
<div className="w-8 h-8 rounded-full bg-gray-500 border-2 border-white dark:border-black"></div>
</div>
<span className="text-xs font-bold">500+ Happy Homes</span>
</div>
<div className="flex items-center space-x-2">
<ShieldCheck size={16} />
<span className="text-xs font-bold">Licensed & Insured</span>
</div>
</div>
</div>
</div>
{/* PROCESS SECTION (From Sky to Safety) */}
<div className="py-24 bg-white dark:bg-black/40 border-y border-zinc-100 dark:border-white/5" ref={addToRefs}>
<div className="max-w-7xl mx-auto px-6">
<div className="text-center mb-24">
<div className="inline-block px-4 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-bold text-xs tracking-widest uppercase mb-4">The Workflow</div>
<h2 className="text-4xl md:text-6xl font-black tracking-tight">From Sky to Safety in <span className="text-blue-500">3 Steps.</span></h2>
</div>
<div className="space-y-32">
{/* Step 1 */}
<div className="grid md:grid-cols-2 gap-16 items-center">
<div className="relative rounded-3xl overflow-hidden shadow-2xl border border-white/10 group order-2 md:order-1">
<img src="/src/assets/images/process.png" alt="The Scan" className="w-full h-auto transform transition-transform duration-700 group-hover:scale-105" />
<div className="absolute bottom-6 left-6 bg-black/70 backdrop-blur-md px-3 py-1 rounded text-xs font-mono text-green-400 border border-green-500/30">LIDAR_MAPPING_ACTIVE</div>
</div>
<div className="order-1 md:order-2">
<div className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center font-black text-xl mb-6">01</div>
<h3 className="text-3xl font-bold mb-4">The Scan.</h3>
<p className="text-lg text-zinc-500 dark:text-zinc-400 leading-relaxed mb-6">
Drones use <strong className="text-zinc-900 dark:text-white">LiDAR imagery</strong> and high-fidelity photogrammetry to map out your roof in sub-millimeter detail. We capture every angle, creating a perfect digital twin of your property without anyone ever stepping foot on a ladder.
</p>
<ul className="space-y-2 text-sm font-medium text-zinc-500">
<li className="flex items-center"><CheckCircle2 size={16} className="mr-2 text-blue-500" /> 100% Contactless Inspection</li>
<li className="flex items-center"><CheckCircle2 size={16} className="mr-2 text-blue-500" /> 15-Minute Flight Time</li>
</ul>
</div>
</div>
{/* Step 2 */}
<div className="grid md:grid-cols-2 gap-16 items-center">
<div className="order-1">
<div className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center font-black text-xl mb-6">02</div>
<h3 className="text-3xl font-bold mb-4">The Diagnosis.</h3>
<p className="text-lg text-zinc-500 dark:text-zinc-400 leading-relaxed mb-6">
This data is sent to our <strong className="text-zinc-900 dark:text-white">AI models</strong>, which instantly diagnose issues invisible to the naked eye. The algorithm highlights storm damage, wear patterns, and insulation failures, scoring them by urgency.
</p>
<div className="border-l-4 border-blue-500 pl-4 italic text-zinc-500">
"Better, faster, and more accurate decisions—eliminating human error."
</div>
</div>
<div className="relative rounded-3xl overflow-hidden shadow-2xl border border-white/10 group order-2">
<img src="/src/assets/images/process-2.png" alt="The Diagnosis" className="w-full h-auto transform transition-transform duration-700 group-hover:scale-105" />
<div className="absolute top-6 right-6 bg-black/80 backdrop-blur-md p-4 rounded-xl border border-red-500/30 hidden md:block">
<div className="flex items-center justify-between text-xs font-mono text-zinc-400 mb-2 gap-4">
<span>SCAN_ID: 8842-A</span>
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs font-bold text-white"><span>Structure Integrity</span> <span className="text-yellow-400">76%</span></div>
<div className="w-32 h-1 bg-zinc-700 rounded-full overflow-hidden"><div className="w-[76%] h-full bg-yellow-400"></div></div>
<div className="flex justify-between text-xs font-bold text-white"><span>Moisture Level</span> <span className="text-red-500">CRITICAL</span></div>
<div className="w-32 h-1 bg-zinc-700 rounded-full overflow-hidden"><div className="w-[90%] h-full bg-red-500"></div></div>
</div>
</div>
</div>
</div>
{/* Step 3 */}
<div className="grid md:grid-cols-2 gap-16 items-center">
<div className="relative rounded-3xl overflow-hidden shadow-2xl border border-white/10 group order-2 md:order-1">
<img src="/src/assets/images/process-3.png" alt="The Resolution" className="w-full h-auto transform transition-transform duration-700 group-hover:scale-105" />
</div>
<div className="order-1 md:order-2">
<div className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center font-black text-xl mb-6">03</div>
<h3 className="text-3xl font-bold mb-4">The Resolution.</h3>
<p className="text-lg text-zinc-500 dark:text-zinc-400 leading-relaxed mb-6">
Armed with precise data, our certified ground team executes the repair cheaply and effectively. Homeowners are assured and relaxed, knowing they are getting the best of the best.
</p>
<div className="flex gap-4">
<span className="px-3 py-1 bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase rounded-md">Cost Efficient</span>
<span className="px-3 py-1 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 text-xs font-bold uppercase rounded-md">Data-Backed</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* COMPARISON SLIDER SECTION */}
<div className="py-24" ref={addToRefs}>
<ComparisonSlider />
</div>
{/* FACT CHECK & STATS */}
<div className="py-24 bg-zinc-900 text-white relative overflow-hidden" ref={addToRefs}>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-10"></div>
<div className="max-w-7xl mx-auto px-6 relative z-10">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-black mb-4">Why Risk The Wait?</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">Delaying roof repairs leads to exponential damage. Here are the facts.</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
<div className="text-4xl font-black text-red-500 mb-2">40%</div>
<h3 className="font-bold text-xl mb-2">Insurance Denials</h3>
<p className="text-zinc-400 text-sm">Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.</p>
</div>
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
<div className="text-4xl font-black text-yellow-500 mb-2">5yrs</div>
<h3 className="font-bold text-xl mb-2">Life Reduced</h3>
<p className="text-zinc-400 text-sm">A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.</p>
</div>
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
<div className="text-4xl font-black text-green-500 mb-2">15m</div>
<h3 className="font-bold text-xl mb-2">Our Estimate Time</h3>
<p className="text-zinc-400 text-sm">While others take days, we give you a quote before our drone even lands.</p>
</div>
</div>
</div>
</div>
{/* TESTIMONIALS */}
<div className="py-32" ref={addToRefs}>
<div className="max-w-7xl mx-auto px-6">
<h2 className="text-4xl font-black text-center mb-16">Neighbors Who Love Us</h2>
<div className="grid md:grid-cols-3 gap-6">
{/* Senior/Veteran Testimonial */}
<SpotlightCard className="p-8 h-full bg-zinc-50 dark:bg-[#0A0A0A]">
<div className="flex items-center gap-4 mb-6">
<img src="/src/assets/images/testimonials.png" alt="Veterans" className="w-16 h-16 rounded-full object-cover border-2 border-emerald-500" />
<div>
<h4 className="font-bold">The Hendersons</h4>
<p className="text-xs text-zinc-500">Retired Veterans Plano, TX</p>
</div>
</div>
<div className="flex text-yellow-500 mb-4"><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /></div>
<p className="text-zinc-600 dark:text-zinc-400 italic">"The veteran discount was such a blessing. They found a leak our previous inspector missed completely. The drone technology was fascinating to watch!"</p>
</SpotlightCard>
<SpotlightCard className="p-8 h-full bg-zinc-50 dark:bg-[#0A0A0A]">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center font-bold text-blue-600 text-xl">JD</div>
<div>
<h4 className="font-bold">James D.</h4>
<p className="text-xs text-zinc-500">Homeowner Frisco, TX</p>
</div>
</div>
<div className="flex text-yellow-500 mb-4"><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /></div>
<p className="text-zinc-600 dark:text-zinc-400 italic">"I requested a quote at 9am and had the repair scheduled by noon. The transparency with the 3D model made me feel confident in the price."</p>
</SpotlightCard>
<SpotlightCard className="p-8 h-full bg-zinc-50 dark:bg-[#0A0A0A]">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 rounded-full bg-purple-100 flex items-center justify-center font-bold text-purple-600 text-xl">SM</div>
<div>
<h4 className="font-bold">Sarah M.</h4>
<p className="text-xs text-zinc-500">Investor Dallas, TX</p>
</div>
</div>
<div className="flex text-yellow-500 mb-4"><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /><Star size={16} fill="currentColor" /></div>
<p className="text-zinc-600 dark:text-zinc-400 italic">"Managing 5 properties is hard. LynkedUpPro makes it easy. I can see the roof status of all my houses on my dashboard. Incredible tool."</p>
</SpotlightCard>
</div>
</div>
</div>
<Chatbot />
</div>
);
};
export default Landing;
+212
View File
@@ -0,0 +1,212 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { User, Briefcase, Lock, ArrowRight, AlertCircle, Home } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
// Rainbow Button Component
const RainbowButton = ({ children, onClick, type = "button", className = "" }) => {
const btnRef = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [opacity, setOpacity] = useState(0);
const handleMouseMove = (e) => {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
return (
<button
ref={btnRef}
onClick={onClick}
type={type}
onMouseMove={handleMouseMove}
onMouseEnter={() => setOpacity(1)}
onMouseLeave={() => setOpacity(0)}
onFocus={() => setOpacity(1)}
onBlur={() => setOpacity(0)}
className={`relative group w-full py-4 rounded-xl font-bold text-white overflow-hidden transition-all duration-300 transform active:scale-[0.98] ${className}`}
>
{/* Rainbow Glow Layer */}
<div
className='pointer-events-none absolute -inset-px opacity-0 transition duration-300'
style={{
opacity,
background: `conic-gradient(from 0deg, #ff0000, #ff8800, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)`,
WebkitMaskImage: `radial-gradient(150px circle at ${position.x}px ${position.y}px, black, transparent 80%)`,
maskImage: `radial-gradient(150px circle at ${position.x}px ${position.y}px, black, transparent 80%)`,
}}
/>
{/* Button Background (Glass/Neo) */}
<div className="absolute inset-[1px] rounded-[11px] bg-zinc-900 border border-white/10 group-hover:bg-zinc-800 transition-colors z-0" />
{/* Content */}
<div className="relative z-10 flex items-center justify-center space-x-2">
{children}
</div>
</button>
);
};
const Login = () => {
const [isEmployee, setIsEmployee] = useState(false);
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleLogin = (e) => {
e.preventDefault();
setError('');
if (!identifier || !password) {
setError('Please fill in all fields');
return;
}
const type = isEmployee ? 'employee' : 'customer';
const result = login(identifier, password, type);
if (result.success) {
if (type === 'customer') {
navigate('/');
} else {
navigate('/emp/fa/dashboard'); // Default for employees
}
} else {
setError(result.message);
}
};
const fillDemo = (role) => {
if (role === 'customer') {
setIsEmployee(false);
setIdentifier('alice');
setPassword('password');
} else if (role === 'agent') {
setIsEmployee(true);
setIdentifier('FA001');
setPassword('password');
} else if (role === 'admin') {
setIsEmployee(true);
setIdentifier('ADM01');
setPassword('password');
}
};
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] flex items-center justify-center p-4 relative overflow-hidden selection:bg-blue-500/20 dark:selection:bg-white/20 transition-colors duration-300">
{/* Subtle Ambient Background */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] dark:opacity-5 pointer-events-none mix-blend-overlay"></div>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 pointer-events-none">
<div className="absolute top-[-20%] left-[-20%] w-[70%] h-[70%] bg-purple-500/5 dark:bg-purple-900/10 blur-[150px] rounded-full opacity-50"></div>
<div className="absolute bottom-[-20%] right-[-20%] w-[70%] h-[70%] bg-blue-500/5 dark:bg-blue-900/10 blur-[150px] rounded-full opacity-50"></div>
</div>
<div className="w-full max-w-md z-10 relative">
<SpotlightCard className="bg-white/60 dark:bg-black/30 backdrop-blur-3xl border-0 ring-1 ring-black/5 dark:ring-white/5 shadow-2xl dark:shadow-2xl">
<div className="p-8">
<div className="text-center mb-10 pt-2">
<div className="w-14 h-14 bg-gradient-to-br from-zinc-100 to-zinc-300 dark:from-white dark:to-zinc-400 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-black/5 dark:shadow-white/5 ring-1 ring-black/5 dark:ring-white/20">
<Home size={28} className="text-black" />
</div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white mb-2 tracking-tighter">
Welcome Back
</h1>
<p className="text-zinc-500 font-medium">Sign in to your Plano Realty account</p>
</div>
{/* Neo-Toggle */}
<div className="flex p-1.5 bg-zinc-100 dark:bg-black/40 rounded-xl mb-8 border border-zinc-200 dark:border-white/5 mx-1">
<button
onClick={() => setIsEmployee(false)}
className={`flex-1 flex items-center justify-center py-2.5 text-xs font-bold uppercase tracking-wider rounded-lg transition-all duration-300 ${!isEmployee
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm border border-black/5 dark:border-white/5'
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'}`}
>
<User size={14} className="mr-2" />
Customer
</button>
<button
onClick={() => setIsEmployee(true)}
className={`flex-1 flex items-center justify-center py-2.5 text-xs font-bold uppercase tracking-wider rounded-lg transition-all duration-300 ${isEmployee
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm border border-black/5 dark:border-white/5'
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'}`}
>
<Briefcase size={14} className="mr-2" />
Employee
</button>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-bold text-zinc-500 ml-1 uppercase tracking-widest">
{isEmployee ? 'Employee ID' : 'Username'}
</label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors">
{isEmployee ? <Briefcase size={18} /> : <User size={18} />}
</div>
<input
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
className="w-full bg-zinc-100 dark:bg-black/20 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-black/40 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium"
placeholder={isEmployee ? "e.g., FA001" : "e.g., alice"}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-zinc-500 ml-1 uppercase tracking-widest">Password</label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors">
<Lock size={18} />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-100 dark:bg-black/20 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-black/40 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium"
placeholder="Enter your password"
/>
</div>
</div>
{error && (
<div className="flex items-center space-x-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 p-3 rounded-xl text-xs font-bold uppercase tracking-wide">
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
<RainbowButton type="submit" className="mt-2 text-white">
<span>Sign In</span>
<ArrowRight size={18} />
</RainbowButton>
</form>
{/* Demo Quick Links */}
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-white/5">
<p className="text-[10px] text-center text-zinc-500 uppercase tracking-widest font-bold mb-4">Quick Demo Access</p>
<div className="flex gap-2 justify-center">
<button onClick={() => fillDemo('customer')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Customer</button>
<button onClick={() => fillDemo('agent')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Agent</button>
<button onClick={() => fillDemo('admin')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Admin</button>
</div>
</div>
</div>
</SpotlightCard>
</div>
</div>
);
};
export default Login;
+615
View File
@@ -0,0 +1,615 @@
import React, { useState, useMemo, useEffect } from 'react';
import { MapContainer, TileLayer, Polygon, useMapEvents, Marker, Popup } from 'react-leaflet';
import { X, Home, DollarSign, User, Info, Edit2, Save, Check, MapPin, Loader2, Plus, AlertCircle } from 'lucide-react';
import 'leaflet/dist/leaflet.css';
import { useMockStore } from '../data/mockStore';
import { useAuth } from '../context/AuthContext';
import { SpotlightCard } from '../components/SpotlightCard';
import { useTheme } from '../context/ThemeContext';
import { logger } from '../utils/logger';
import { toast } from 'sonner';
// Fix for default Leaflet marker icons not showing up
import L from 'leaflet';
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
let DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
// --- COMPONENTS ---
const StatusBadge = ({ status }) => {
const colors = {
"Neutral": "bg-zinc-500",
"Hot Lead": "bg-red-500",
"Renovated": "bg-blue-500",
"Customer": "bg-emerald-500",
"Not Interested": "bg-black"
};
return (
<span className={`px-2 py-1 rounded text-[10px] uppercase font-bold text-white shadow-sm ${colors[status] || "bg-zinc-400"}`}>
{status}
</span>
);
};
// Map Interaction Component
const MapInteraction = ({ onMapClick }) => {
useMapEvents({
click(e) {
onMapClick(e.latlng);
},
});
return null;
};
// Map View
const MapView = ({ data, onSelect, onMapCreate }) => {
const mapStart = [33.0708, -96.7455];
const { theme } = useTheme(); // Get current theme
return (
<MapContainer center={mapStart} zoom={18} scrollWheelZoom={true} className="w-full h-full z-0 relative outline-none" style={{ height: "100%", width: "100%" }}>
{/*
- Light Mode: Standard vibrant OSM tiles (no filters)
- Dark Mode: Inverted, Grayscale, High Contrast for dark map
- Key prop forces remount on theme change to ensure tiles update immediately
*/}
<TileLayer
key={theme}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className={theme === 'dark'
? 'map-tiles filter invert grayscale contrast-100'
: 'map-tiles'
}
/>
<MapInteraction onMapClick={onMapCreate} />
{data.map((item) => {
let color = "#71717a"; // zinc-500 default
// Adjust colors for light/dark mode visibility if needed, or keep consistent
if (item.canvassingStatus === "Hot Lead") color = "#ef4444"; // red-500
if (item.canvassingStatus === "Customer") color = "#10b981"; // emerald-500
if (item.canvassingStatus === "Renovated") color = "#3b82f6"; // blue-500
return (
<Polygon
key={item.id}
positions={item.polygon}
pathOptions={{
color: color,
fillColor: color,
fillOpacity: 0.2,
weight: 2
}}
eventHandlers={{
click: (e) => {
L.DomEvent.stopPropagation(e); // Prevent map click
onSelect(item);
},
}}
/>
);
})}
</MapContainer>
);
};
const Drawer = ({ isOpen, onClose, data, newLocation, onUpdateStatus, onSave, loadingAddr }) => {
const [isEditing, setIsEditing] = useState(false);
// Initial State Matching Schema
const initialFormState = {
// Status
status: 'Neutral',
// 1.1 Basic Property Details
propertyType: 'House',
builtUpArea: '',
lotSize: '',
yearBuilt: '',
bedrooms: '',
bathrooms: '',
parkingSpaces: '',
// 1.2 Transaction & Market
purchaseDate: '',
purchasePrice: '',
marketValue: 0,
taxValue: '',
// 1.3 Renovation
roofCondition: 'Good',
lastRenovationDate: '',
// 1.4 Rental Status
isRented: 'No', // Yes/No
tenantName: '',
currentRent: '',
leaseEnd: '',
ownerWillingToRent: 'Yes',
expectedRent: '',
// 1.5 Location
schoolDistrict: '',
neighborhoodRating: 5,
// 2.1 Owner Details
ownerName: '',
ownerPhone: '',
ownerEmail: '',
ownerAddress: '',
occupation: '',
ownershipType: 'Individual',
// 2.4 Owner Intent
willingToSell: 'No',
desiredPrice: '',
minPrice: '',
// Notes
notes: ''
};
const [formData, setFormData] = useState(initialFormState);
// Reset or Initialize Form
useEffect(() => {
if (data) {
// Mapping existing data to form state (handling potentially missing fields safely)
setFormData({
...initialFormState,
status: data.canvassingStatus || 'Neutral',
ownerName: data.ownerData?.fullName || '',
ownerPhone: data.ownerData?.primaryPhoneNumber || '',
ownerEmail: data.ownerData?.emailAddress || '',
marketValue: data.propertyData?.currentEstimatedMarketValue || 0,
notes: data.propertyData?.notes || '',
propertyType: data.propertyData?.propertyType || 'House',
// Map other fields if they existed in data, otherwise defaults
...data.propertyData?.details, // Assuming we store extra details in a nested object `details` for now or spread root
...data.ownerData?.details
});
setIsEditing(false);
} else if (newLocation) {
setFormData(initialFormState);
setIsEditing(true);
}
}, [data, newLocation]);
const handleSave = () => {
onSave(formData);
if (data) setIsEditing(false);
};
if (!isOpen) return null;
const isCreating = !data && !!newLocation;
const address = data?.propertyData?.propertyAddress || newLocation?.address || "Unknown Location";
const coords = data ? null : newLocation;
// Helper for Inputs
const RenderInput = ({ label, field, type = "text", placeholder, options, locked = false }) => {
if (!isEditing) {
return (
<InputGroup label={label}>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
{formData[field] || "-"}
</span>
</InputGroup>
);
}
if (locked) {
return (
<InputGroup label={label}>
<div className="w-full bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 rounded-lg py-2 px-3 text-sm text-zinc-400 cursor-not-allowed italic">
{placeholder || "Not Applicable"}
</div>
</InputGroup>
);
}
return (
<InputGroup label={label}>
{options ? (
<select
value={formData[field]}
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg py-2 px-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 appearance-none"
>
{options.map(opt => (
<option key={opt} value={opt} className="bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white">
{opt}
</option>
))}
</select>
) : (
<input
type={type}
value={formData[field]}
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
placeholder={placeholder}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg py-2 px-3 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder-zinc-400"
/>
)}
</InputGroup>
);
};
return (
<div className={`fixed top-4 right-4 bottom-4 w-[500px] z-[1000] flex flex-col transition-transform duration-300 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+2rem)]'}`}>
<SpotlightCard className="h-full flex flex-col overflow-hidden bg-white/95 dark:bg-zinc-900/95 backdrop-blur-3xl border-l border-white/20 shadow-2xl">
{/* Header */}
<div className="relative h-32 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-900 dark:to-black p-6 flex flex-col justify-end shrink-0 border-b border-zinc-200 dark:border-white/5">
{/* Pattern */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] dark:opacity-10 pointer-events-none mix-blend-overlay"></div>
<button onClick={onClose} className="absolute top-4 right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-colors backdrop-blur-md text-zinc-900 dark:text-white">
<X size={18} />
</button>
<div className="relative z-10">
<div className="flex items-center space-x-2 mb-1">
{isCreating ? <span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-500 text-white">New Entry</span> : <StatusBadge status={isEditing ? formData.status : data.canvassingStatus} />}
</div>
<h2 className="text-xl font-black text-zinc-900 dark:text-white truncate">{loadingAddr ? "Fetching Address..." : address.split(',')[0]}</h2>
<span className="text-xs text-zinc-500 truncate block">{loadingAddr ? "Locating..." : address}</span>
</div>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-8 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
{loadingAddr ? (
<div className="flex flex-col items-center justify-center h-40 space-y-3 text-zinc-500">
<Loader2 className="animate-spin" size={24} />
<span className="text-xs font-bold uppercase">Resolving Location...</span>
</div>
) : (
<>
{/* Status Selector */}
{isEditing && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold text-zinc-500 tracking-widest ml-1">Canvassing Status</label>
<div className="grid grid-cols-4 gap-2">
{["Neutral", "Hot Lead", "Customer", "Not Interested"].map(s => (
<button key={s} onClick={() => setFormData({ ...formData, status: s })} className={`px-1 py-2 rounded text-[10px] font-bold border transition-all ${formData.status === s ? 'bg-zinc-900 dark:bg-white text-white dark:text-black' : 'bg-transparent border-zinc-200 dark:border-white/10 text-zinc-500'}`}>{s}</button>
))}
</div>
</div>
)}
{/* SECTION 1: BASIC PROPERTY INFO */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-blue-600 dark:text-blue-400">
<Home size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Property Basics</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Property Type" field="propertyType" options={["House", "Apartment", "Condo", "Commercial", "Land"]} />
<RenderInput label="Built-Up Area (sqft)" field="builtUpArea" type="number" />
<RenderInput label="Lot Size (sqft)" field="lotSize" type="number" />
<RenderInput label="Year Built" field="yearBuilt" type="number" />
<div className="grid grid-cols-3 gap-2 col-span-2">
<RenderInput label="Beds" field="bedrooms" type="number" />
<RenderInput label="Baths" field="bathrooms" type="number" />
<RenderInput label="Parking" field="parkingSpaces" type="number" />
</div>
</div>
</div>
{/* SECTION 2: VALUES & CONDITION */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-emerald-600 dark:text-emerald-400">
<DollarSign size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Value & Condition</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Est. Market Value ($)" field="marketValue" type="number" />
<RenderInput label="Tax Assessed Value ($)" field="taxValue" type="number" />
<RenderInput label="Roof Condition" field="roofCondition" options={["Excellent", "Good", "Fair", "Needs Repair", "Critical"]} />
<RenderInput label="Last Reno Date" field="lastRenovationDate" type="date" />
</div>
</div>
{/* SECTION 3: RENTAL STATUS (Conditional) */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-purple-600 dark:text-purple-400">
<div className="p-1 bg-purple-100 dark:bg-purple-900/30 rounded"><User size={12} /></div>
<h3 className="text-xs font-black uppercase tracking-widest">Rental Status</h3>
</div>
<RenderInput label="Currently Rented?" field="isRented" options={["Yes", "No"]} />
{formData.isRented === 'Yes' ? (
<div className="p-4 bg-purple-50 dark:bg-purple-900/10 rounded-xl space-y-4 border border-purple-100 dark:border-purple-500/20">
<RenderInput label="Tenant Name" field="tenantName" />
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Current Rent" field="currentRent" type="number" />
<RenderInput label="Lease End" field="leaseEnd" type="date" />
</div>
</div>
) : (
<div className="p-4 bg-zinc-50 dark:bg-white/5 rounded-xl space-y-4">
<RenderInput label="Owner Willing to Rent?" field="ownerWillingToRent" options={["Yes", "No", "Maybe"]} />
{formData.ownerWillingToRent !== 'No' && (
<RenderInput label="Expected Rent ($)" field="expectedRent" type="number" />
)}
</div>
)}
</div>
{/* SECTION 4: OWNER INTENT (Conditional) */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-amber-600 dark:text-amber-400">
<AlertCircle size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Recall & Sales Intent</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Willing to Sell?" field="willingToSell" options={["Yes", "No", "Undecided"]} />
<RenderInput label="Ownership Type" field="ownershipType" options={["Individual", "Joint", "Corporate", "Trust"]} />
</div>
{formData.willingToSell === 'Yes' ? (
<div className="p-4 bg-amber-50 dark:bg-amber-900/10 rounded-xl space-y-4 border border-amber-100 dark:border-amber-500/20">
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Desired Price" field="desiredPrice" type="number" />
<RenderInput label="Min Price" field="minPrice" type="number" />
</div>
</div>
) : (
<RenderInput label="Desired Price" field="desiredPrice" locked={true} placeholder="Owner not selling" />
)}
</div>
{/* SECTION 5: OWNER CONTACT */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-zinc-500">
<User size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Owner Contact</h3>
</div>
<RenderInput label="Full Name" field="ownerName" />
<div className="grid grid-cols-2 gap-4">
<RenderInput label="Phone" field="ownerPhone" />
<RenderInput label="Email" field="ownerEmail" />
</div>
<RenderInput label="Occupation" field="occupation" />
</div>
{/* SECTION 6: NOTES */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-zinc-500">
<Edit2 size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Field Notes</h3>
</div>
{isEditing ? (
<textarea
className="w-full h-32 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl p-3 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 resize-none"
value={formData.notes}
placeholder="Add notes..."
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400 bg-zinc-50 dark:bg-white/5 p-4 rounded-xl border border-zinc-200 dark:border-white/5">
{formData.notes || "No notes available."}
</p>
)}
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20 backdrop-blur-md shrink-0">
{isEditing ? (
<button onClick={handleSave} disabled={loadingAddr} className="w-full py-3 bg-zinc-900 dark:bg-white hover:bg-zinc-800 text-white dark:text-black font-bold rounded-xl shadow-lg transition-all flex items-center justify-center space-x-2 disabled:opacity-50">
<Save size={18} /> <span>{isCreating ? "Create Entry" : "Save Changes"}</span>
</button>
) : (
<button onClick={() => setIsEditing(true)} className="w-full py-3 bg-white dark:bg-zinc-800 hover:bg-zinc-50 text-zinc-900 dark:text-white border border-zinc-200 dark:border-white/10 font-bold rounded-xl shadow-sm transition-all flex items-center justify-center space-x-2">
<Edit2 size={18} /> <span>Edit Details</span>
</button>
)}
</div>
</SpotlightCard>
</div>
);
};
const InputGroup = ({ label, children }) => (
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest ml-0.5">{label}</label>
<div>{children}</div>
</div>
);
// --- MAIN PAGE COMPONENT --- //
function Maps() {
const { properties, setProperties, updatePropertyStatus } = useMockStore();
const { user } = useAuth();
// Selection State
const [selectedPropertyId, setSelectedPropertyId] = useState(null);
const [newPropertyLocation, setNewPropertyLocation] = useState(null); // { lat, lng, address }
const [loadingAddr, setLoadingAddr] = useState(false);
// Derived Selection
const selectedProperty = useMemo(() =>
properties.find(p => p.id === selectedPropertyId),
[properties, selectedPropertyId]
);
// -- Handlers --
// 1. Click Existing Polygon
const handlePropertySelect = (property) => {
setNewPropertyLocation(null); // Clear new creation mode
setSelectedPropertyId(property.id);
};
// 2. Click Empty Map (Create New)
const handleMapCreate = async (latlng) => {
// Only Field Agents (employees) can create
if (user?.role !== 'FIELD_AGENT' && user?.role !== 'ADMIN') return;
setSelectedPropertyId(null);
setNewPropertyLocation({ lat: latlng.lat, lng: latlng.lng, address: '' });
setLoadingAddr(true);
try {
// Reverse Geocode using simple OSM Nominatim API
// Note: In production, user should cache/throttle or use paid service.
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
if (!response.ok) throw new Error("Geocoding service unavailable");
const data = await response.json();
setNewPropertyLocation(prev => ({
...prev,
address: data.display_name || "Unknown Location"
}));
logger.info("Geocoding successful", { lat: latlng.lat, lng: latlng.lng });
} catch (err) {
logger.error("Geocoding failed", err);
toast.error("Could not fetch address", { description: "Using coordinates instead." });
setNewPropertyLocation(prev => ({ ...prev, address: "Location Found (Address Unavailable)" }));
} finally {
setLoadingAddr(false);
}
};
// 3. Save Changes (Create or Update)
const handleSave = (formData) => {
try {
// Construct the full data objects based on schema
const newOwnerData = {
fullName: formData.ownerName,
primaryPhoneNumber: formData.ownerPhone || "Pending",
emailAddress: formData.ownerEmail || "Pending",
details: {
occupation: formData.occupation,
ownershipType: formData.ownershipType,
willingToSell: formData.willingToSell,
desiredPrice: formData.desiredPrice,
minPrice: formData.minPrice,
ownerAddress: formData.ownerAddress
}
};
const newPropertyData = {
propertyType: formData.propertyType,
currentEstimatedMarketValue: parseInt(formData.marketValue) || 0,
notes: formData.notes,
details: {
builtUpArea: formData.builtUpArea,
lotSize: formData.lotSize,
yearBuilt: formData.yearBuilt,
bedrooms: formData.bedrooms,
bathrooms: formData.bathrooms,
parkingSpaces: formData.parkingSpaces,
taxValue: formData.taxValue,
roofCondition: formData.roofCondition,
lastRenovationDate: formData.lastRenovationDate,
// Rental Status
isRented: formData.isRented,
tenantName: formData.tenantName,
currentRent: formData.currentRent,
leaseEnd: formData.leaseEnd,
ownerWillingToRent: formData.ownerWillingToRent,
expectedRent: formData.expectedRent,
// Location (can be expanded)
schoolDistrict: formData.schoolDistrict,
neighborhoodRating: formData.neighborhoodRating
}
};
if (selectedPropertyId) {
// Update Existing
setProperties(prev => prev.map(p => {
if (p.id === selectedPropertyId) {
return {
...p,
canvassingStatus: formData.status,
ownerData: { ...p.ownerData, ...newOwnerData, details: { ...p.ownerData.details, ...newOwnerData.details } },
propertyData: {
...p.propertyData,
...newPropertyData,
details: { ...p.propertyData.details, ...newPropertyData.details } // Merge to keep existing data not in form if any
}
};
}
return p;
}));
} else if (newPropertyLocation) {
// Create New
const newId = `prop-${Date.now()}`;
const newProp = {
id: newId,
polygon: [], // We don't have a polygon drawing tool yet, implies point-based or empty
canvassingStatus: formData.status,
ownerData: newOwnerData,
propertyData: {
...newPropertyData,
propertyAddress: newPropertyLocation.address,
}
};
// In a real app we'd need to store the LatLng for this new valid prop separately if it has no polygon
// For now, we just add it to the list.
// NOTE: Since MapsView maps *Polygons*, this new item won't appear on map unless we add a Marker layer or mock a polygon around the point.
// For MVP, we will mock a tiny square polygon around the point so it renders.
const lat = newPropertyLocation.lat;
const lng = newPropertyLocation.lng;
const size = 0.0001; // Tiny box
newProp.polygon = [
[lat + size, lng - size],
[lat + size, lng + size],
[lat - size, lng + size],
[lat - size, lng - size]
];
setProperties(prev => [...prev, newProp]);
setSelectedPropertyId(newId); // Select the new item
setNewPropertyLocation(null); // Clear creation mode
}
toast.success("Property data saved successfully");
} catch (error) {
logger.error("Error saving property data", error);
toast.error("Failed to save changes", { description: "Please try again." });
}
};
return (
<div className="w-full h-full relative bg-zinc-50 dark:bg-black overflow-hidden transition-colors duration-300" style={{ height: "calc(100vh - 0px)" }}>
<MapView
data={properties}
onSelect={handlePropertySelect}
onMapCreate={handleMapCreate}
/>
<Drawer
isOpen={!!selectedPropertyId || !!newPropertyLocation}
onClose={() => { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
data={selectedProperty}
newLocation={newPropertyLocation}
onSave={handleSave}
loadingAddr={loadingAddr}
/>
</div>
);
}
export default Maps;
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import { Home, ArrowLeft, Search } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
const NotFound = () => {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black text-zinc-900 dark:text-white flex flex-col items-center justify-center p-6 relative overflow-hidden">
{/* Background Texture */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' viewBox=\'0 0 60 60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'none\' fill-rule=\'evenodd\'%3E%3Cg fill=\'%239C92AC\' fill-opacity=\'0.4\'%3E%3Cpath d=\'M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")' }}></div>
{/* Content */}
<div className="relative z-10 text-center max-w-lg">
<div className="w-24 h-24 bg-zinc-100 dark:bg-zinc-900 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-inner border border-zinc-200 dark:border-zinc-800 rotate-12">
<Search size={40} className="text-zinc-400 dark:text-zinc-600" />
</div>
<h1 className="text-8xl font-black tracking-tighter mb-2 text-transparent bg-clip-text bg-gradient-to-b from-zinc-900 to-zinc-400 dark:from-white dark:to-zinc-500">
404
</h1>
<h2 className="text-2xl font-bold mb-4">Roof Not Found</h2>
<p className="text-zinc-500 dark:text-zinc-400 mb-10 leading-relaxed">
It seems you've wandered off the blueprint. The property or page you are looking for doesn't exist or has been demolished.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => navigate(-1)}
className="w-full sm:w-auto px-6 py-3 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors font-bold text-sm flex items-center justify-center"
>
<ArrowLeft size={16} className="mr-2" />
Go Back
</button>
<Link
to="/"
className="w-full sm:w-auto px-6 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-black hover:opacity-90 transition-opacity font-bold text-sm flex items-center justify-center shadow-lg shadow-zinc-500/20"
>
<Home size={16} className="mr-2" />
Return Home
</Link>
</div>
</div>
{/* Footer */}
<div className="absolute bottom-8 text-[10px] uppercase font-bold tracking-widest text-zinc-300 dark:text-zinc-700">
LynkedUpPro Infrastructure
</div>
</div>
);
};
export default NotFound;