0 ? 'bg-zinc-50 dark:bg-[#121214]' : 'bg-transparent'
+ }`} />
+
+ {/* Content (Z-index to sit above the mask) */}
+
`absolute right-2 w-1.5 h-1.5 rounded-full bg-zinc-900 dark:bg-white transition-all duration-300 ${isActive ? 'opacity-100 scale-100' : 'opacity-0 scale-0'}`} />
+
+ );
+};
+
+const Layout = () => {
+ const { user, logout } = useAuth();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const handleLogout = () => {
+ logout();
+ navigate('/login');
+ };
+
+ // Determine standard layout vs full screen for Landing/Login
+ const isPublic = ['/', '/login'].includes(location.pathname);
+
+ if (isPublic) {
+ return
;
+ }
+
+ return (
+
+ {/* Sidebar */}
+ {/* Enhanced glassmorphism with light/dark support */}
+
+
+ {/* Noise texture */}
+
+
+
+
+
+
+
+
+ Plano
+ Realty
+
+
+
+
+ Menu
+
+ {user?.role === 'FIELD_AGENT' || user?.role === 'ADMIN' ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {user?.role === 'ADMIN' && (
+
+ )}
+
+ {/* Common Links */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{user?.name}
+
{user?.role?.replace('_', ' ').toLowerCase()}
+
+
+
+
+
+ Sign Out
+
+
+
+
+ {/* Main Content */}
+
+
+
+
+
+
+ );
+};
+
+export default Layout;
diff --git a/src/components/SpotlightCard.jsx b/src/components/SpotlightCard.jsx
new file mode 100644
index 0000000..99be2ce
--- /dev/null
+++ b/src/components/SpotlightCard.jsx
@@ -0,0 +1,84 @@
+import React, { useRef, useState } from 'react';
+
+export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba(255, 255, 255, 0.25)" }) => {
+ const divRef = useRef(null);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [opacity, setOpacity] = useState(0);
+
+ const handleMouseMove = (e) => {
+ if (!divRef.current) return;
+
+ const div = divRef.current;
+ const rect = div.getBoundingClientRect();
+
+ setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
+ };
+
+ const handleFocus = () => {
+ setOpacity(1);
+ };
+
+ const handleBlur = () => {
+ setOpacity(0);
+ };
+
+ const handleMouseEnter = () => {
+ setOpacity(1);
+ };
+
+ const handleMouseLeave = () => {
+ setOpacity(0);
+ };
+
+ return (
+
+ {/*
+ Rainbow Glow Layer
+ - Background: Full conic RGB rainbow (Works on both, provides the color)
+ - Mask reveals transparency
+ */}
+
+
+ {/*
+ Inner Mask (Content Container)
+ - Light Mode: bg-white/90 (Opaque white with slight blur)
+ - Dark Mode: bg-zinc-950/90
+ */}
+
+
+ {/* Surface Shine (Subtle top sheen) */}
+
+
+ {/* Light Mode Border (to separate card when not glowing) */}
+
+
+ {/* Content */}
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/landing/constants.jsx b/src/components/landing/constants.jsx
new file mode 100644
index 0000000..699fc99
--- /dev/null
+++ b/src/components/landing/constants.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+
+// Brand Colors for reference in logic if needed
+export const COLORS = {
+ NAVY: '#0B1C3E',
+ CYAN: '#00E5FF',
+ PATRIOT: '#D62828',
+ STEEL: '#F4F7F6',
+ WHITE: '#FFFFFF',
+};
+
+export const HERO_COPY = {
+ headline: "The Future of American Roofing is Here.",
+ subheadline: "AI Precision. Drone Speed. Human Expertise.",
+ supporting: "Faster inspections. More accurate diagnoses. Safer repairs.",
+ formHeader: "Get Your Free Roof Scan",
+ microcopy: "Veteran & Senior discounts available.",
+};
+
+export const FACTS_COPY = {
+ header: "CRITICAL INTELLIGENCE",
+ title: "Why Inspections Are Non-Negotiable",
+ fact1: "Micro-fissures in asphalt shingles can expand by 500% during a single freeze-thaw cycle, turning a $200 repair into a $20,000 replacement.",
+ fact2: "If your roof hasn't been inspected in 5 years, you are statistically 40% more likely to suffer catastrophic failure during a Category 1 storm.",
+};
+
+export const COMPARISON_DATA = [
+ { aspect: "Inspection Time", old: "1-3 Hours", new: "15-20 Minutes" },
+ { aspect: "Safety Risk", old: "High (Ladder climbing)", new: "Zero (Drone deployed)" },
+ { aspect: "Data Accuracy", old: "Subjective Human Eye", new: "AI + Thermal Imaging" },
+ { aspect: "Cost Efficiency", old: "Standard Market Rate", new: "~30% Lower Costs" },
+];
+
+// SVG Icons components
+export const Icons = {
+ Drone: (props) => (
+
+
+
+ ),
+ Shield: (props) => (
+
+
+
+ ),
+ Chart: (props) => (
+
+
+
+ ),
+ MapPin: (props) => (
+
+
+
+ ),
+ Check: (props) => (
+
+
+
+ ),
+ Star: (props) => (
+
+
+
+ ),
+ Phone: (props) => (
+
+
+
+ ),
+ Warning: (props) => (
+
+
+
+ ),
+ Clock: (props) => (
+
+
+
+ )
+};
diff --git a/src/config/env.js b/src/config/env.js
new file mode 100644
index 0000000..f0a76ca
--- /dev/null
+++ b/src/config/env.js
@@ -0,0 +1,23 @@
+/**
+ * Centralized Environment Configuration
+ * Handles environment variables with safe defaults.
+ */
+
+const DEFAULT_KEY_PLACEHOLDER = "Your-API-Key";
+
+export const config = {
+ // Groq AI API Key
+ groqApiKey: import.meta.env.VITE_GROQ_API_KEY || DEFAULT_KEY_PLACEHOLDER,
+
+ // OpenWeatherMap API Key
+ // Fallback to the known working demo key if no env var is set
+ weatherApiKey: import.meta.env.VITE_OPENWEATHER_API_KEY || "7bca9386645d390690e65f474dff6b5d",
+
+ // Helper to check if a key is valid (not missing and not the placeholder)
+ isValidKey: (key) => key && key !== DEFAULT_KEY_PLACEHOLDER,
+
+ // Helper to check if we should run in Demo Mode
+ // We only trigger demo mode if the key is the placeholder OR explicitly missing
+ // (The working weather key will NOT trigger demo mode)
+ isDemoMode: (key) => !key || key === DEFAULT_KEY_PLACEHOLDER
+};
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..2139969
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -0,0 +1,59 @@
+import React, { createContext, useContext, useState } from 'react';
+import { useMockStore } from '../data/mockStore';
+import { logger } from '../utils/logger';
+import { toast } from 'sonner';
+
+const AuthContext = createContext();
+
+export const AuthProvider = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const { users } = useMockStore();
+
+ const login = (identifier, password, type) => {
+ try {
+ const foundUser = users.find(u => {
+ if (u.type !== type) return false;
+ if (type === 'customer') return u.username === identifier && u.password === password;
+ if (type === 'employee') return u.empId === identifier && u.password === password;
+ return false;
+ });
+
+ if (foundUser) {
+ setUser(foundUser);
+ setIsAuthenticated(true);
+ logger.info("User logged in successfully", { userId: foundUser.id, role: foundUser.role });
+ toast.success(`Welcome back, ${foundUser.name}!`);
+ return { success: true, role: foundUser.role || 'CUSTOMER' };
+ }
+
+ logger.warn("Failed login attempt", { identifier, type });
+ toast.error("Invalid credentials", { description: "Please check your username/ID and password." });
+ return { success: false, message: "Invalid credentials" };
+ } catch (error) {
+ logger.error("Login process error", error);
+ toast.error("An unexpected error occurred during login.");
+ return { success: false, message: "System error" };
+ }
+ };
+
+ const logout = () => {
+ try {
+ const userName = user?.name;
+ setUser(null);
+ setIsAuthenticated(false);
+ logger.info("User logged out", { userId: user?.id });
+ toast.info("Logged out successfully");
+ } catch (error) {
+ logger.error("Logout error", error);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx
new file mode 100644
index 0000000..efff0b6
--- /dev/null
+++ b/src/context/ThemeContext.jsx
@@ -0,0 +1,24 @@
+import React, { createContext, useContext, useEffect, useState } from 'react';
+
+const ThemeContext = createContext();
+
+export const ThemeProvider = ({ children }) => {
+ // Force Dark Mode always
+ const theme = 'dark';
+
+ useEffect(() => {
+ const root = window.document.documentElement;
+ root.classList.remove('light');
+ root.classList.add('dark');
+ }, []);
+
+ const toggleTheme = () => { }; // No-op
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTheme = () => useContext(ThemeContext);
diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx
new file mode 100644
index 0000000..f9a861a
--- /dev/null
+++ b/src/data/mockStore.jsx
@@ -0,0 +1,385 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+
+// --- DATA CONSTANTS (Extracted from old App.jsx) ---
+
+const RAW_LOCATIONS = [
+ // Residential
+ { address: "2612 Dunwick Dr, Plano, TX 75023", lat: 33.07084060970922, lng: -96.74558398435258, type: "Residential" },
+ { address: "2608 Dunwick Dr, Plano, TX 75023", lat: 33.070834939506284, lng: -96.74532009555337, type: "Residential" },
+ { address: "2604 Dunwick Dr, Plano, TX 75023", lat: 33.07081036862272, lng: -96.74507199497292, type: "Residential" },
+ { address: "2600 Dunwick Dr, Plano, TX 75023", lat: 33.07083115937081, lng: -96.74483066077192, type: "Residential" },
+ { address: "2516 Dunwick Dr, Plano, TX 75023", lat: 33.070770677180924, lng: -96.74459158203076, type: "Residential" },
+ { address: "2637 Rothland Ln, Plano, TX 75023", lat: 33.070426683935366, lng: -96.74557721797312, type: "Residential" },
+ { address: "2633 Rothland Ln, Plano, TX 75023", lat: 33.07042290378234, lng: -96.74534265015158, type: "Residential" },
+ { address: "2629 Rothland Ln, Plano, TX 75023", lat: 33.07042857401182, lng: -96.74508552773183, type: "Residential" },
+ { address: "2617 Rothland Ln, Plano, TX 75023", lat: 33.070432354164595, lng: -96.74435024782977, type: "Residential" },
+ { address: "2513 Rothland Ln, Plano, TX 75023", lat: 33.07016207283135, lng: -96.74304208113281, type: "Residential" },
+ // New Residential Data
+ { address: "6613 Phoenix Pl, Plano, TX 75023, USA", lat: 33.063266298016536, lng: -96.76932248857813, type: "Residential" },
+ { address: "6609 Phoenix Pl, Plano, TX 75023, USA", lat: 33.063141994627365, lng: -96.76932089372718, type: "Residential" },
+ { address: "6605 Phoenix Pl, Plano, TX 75023, USA", lat: 33.06302437405446, lng: -96.76933365253466, type: "Residential" },
+ { address: "6601 Phoenix Pl, Plano, TX 75023, USA", lat: 33.06290541672423, lng: -96.7693424242148, type: "Residential" },
+ { address: "6617 Phoenix Pl, Plano, TX 75023, USA", lat: 33.063379067434504, lng: -96.7693376006764, type: "Residential" },
+ { address: "3913 Arizona Pl, Plano, TX 75023, USA", lat: 33.06318117622371, lng: -96.7698079594919, type: "Residential" },
+ { address: "3917 Arizona Pl, Plano, TX 75023, USA", lat: 33.063193097393324, lng: -96.76997201609075, type: "Residential" },
+ { address: "3921 Arizona Pl, Plano, TX 75023, USA", lat: 33.0631946868825, lng: -96.77014555573001, type: "Residential" },
+ { address: "3920 Arizona Pl, Plano, TX 75023, USA", lat: 33.06252788822516, lng: -96.77010020638036, type: "Residential" },
+ { address: "3916 Arizona Pl, Plano, TX 75023, USA", lat: 33.06254219373469, lng: -96.76993520147747, type: "Residential" },
+ { address: "3912 Arizona Pl, Plano, TX 75023, USA", lat: 33.06254378323562, lng: -96.76975502370993, type: "Residential" },
+ { address: "3908 Arizona Pl, Plano, TX 75023, USA", lat: 33.06253583573075, lng: -96.76959191541512, type: "Residential" },
+ { address: "3904 Arizona Pl, Plano, TX 75023, USA", lat: 33.062526298723945, lng: -96.769445876593, type: "Residential" },
+ { address: "3900 Arizona Pl, Plano, TX 75023, USA", lat: 33.06252153022017, lng: -96.76928656151433, type: "Residential" },
+ { address: "3917 Sailmaker Ln, Plano, TX 75023, USA", lat: 33.0622004503711, lng: -96.76993899469362, type: "Residential" },
+ { address: "3913 Sailmaker Ln, Plano, TX 75023, USA", lat: 33.06218773431336, lng: -96.76979295587151, type: "Residential" },
+ { address: "3909 Sailmaker Ln, Plano, TX 75023, USA", lat: 33.06220362938525, lng: -96.76961846792823, type: "Residential" },
+ { address: "3905 Sailmaker Ln, Plano, TX 75023, USA", lat: 33.06221157692012, lng: -96.76945725624147, type: "Residential" },
+ { address: "3901 Sailmaker Ln, Plano, TX 75023, USA", lat: 33.06219091332797, lng: -96.7692808716901, type: "Residential" },
+
+ // Commercial
+ { address: "6909 Custer Rd, Plano, TX 75023", lat: 33.07020596727293, lng: -96.7390826077087, type: "Commercial" },
+ { address: "2112 Winslow Dr, Plano, TX 75023", lat: 33.069360828751016, lng: -96.73770127012321, type: "Commercial" },
+ { address: "7224 Independence Pkwy, Plano, TX 75025", lat: 33.07506839285908, lng: -96.74984052259599, type: "Commercial" },
+ // New Commercial Data
+ { address: "Dallas/Plano Marriott at Legacy Town Center, 7121 Bishop Rd, Plano, TX 75024, United States", lat: 33.074457402913424, lng: -96.8222322519585, type: "Commercial" },
+ { address: "Legacy Town Center Plano, 5760 Daniel Rd, Plano, TX 75024, United States", lat: 33.07360554164325, lng: -96.82142050370054, type: "Commercial" },
+ { address: "WeWork Office Space & Coworking, 6900 Dallas Pkwy Suite 300, Plano, TX 75024, United States", lat: 33.07281088750022, lng: -96.8235902233433, type: "Commercial" },
+ { address: "Truluck's Ocean's Finest Seafood and Crab, 7161 Bishop Rd, Plano, TX 75024, United States", lat: 33.07535565705897, lng: -96.82174939646889, type: "Commercial" },
+ { address: "Del Frisco's Grille, 7200 Bishop Rd Suite D9, Plano, TX 75024, United States", lat: 33.07630819707901, lng: -96.82124417346868, type: "Commercial" },
+ { address: "Scruffy Duffies, 5865 Kincaid Rd E8, Plano, TX 75024, United States", lat: 33.07601336437884, lng: -96.82257940570773, type: "Commercial" },
+ { address: "Half Shells, 7201 Bishop Rd #E4, Plano, TX 75024, United States", lat: 33.07635733578195, lng: -96.82171330913596, type: "Commercial" },
+ { address: "Mi Cocina, 5760 Legacy Dr #B7, Plano, TX 75024, United States", lat: 33.07743082031222, lng: -96.82109531315771, type: "Commercial" },
+ { address: "Leasing Office space and The Kincaid at Legacy Apartment, 7200 Dallas Pkwy, Plano, TX 75024, United States", lat: 33.07619102009707, lng: -96.82340039310428, type: "Commercial" },
+ { address: "Issil Beauty Spa, 7140 Bishop Rd #F4, Plano, TX 75024, United States", lat: 33.07550110630488, lng: -96.82122287598153, type: "Commercial" },
+
+ // Apartments
+ { address: "2100 Legacy Dr, Plano, TX 75023", lat: 33.07035321131275, lng: -96.73493675556306, type: "Apartments" },
+ { address: "2401 Brown Deer Trail, Plano, TX 75023", lat: 33.06897509321587, lng: -96.7419549494157, type: "Residential" },
+];
+
+const PROPERTY_STYLES = [
+ {
+ label: "Luxury Estate",
+ canvassingStatus: "Customer",
+ photos: ["https://images.unsplash.com/photo-1600596542815-2a4d9f6facb8?w=800", "https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800", "https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800"],
+ baseValue: 850000,
+ areaBase: 4200,
+ beds: 5,
+ baths: 4.5,
+ yearBase: 2010,
+ desc: "Luxury estate with custom finishes throughout.",
+ condition: 5
+ },
+ {
+ label: "Standard Family Home",
+ canvassingStatus: "Neutral",
+ photos: ["https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800", "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800", "https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800"],
+ baseValue: 450000,
+ areaBase: 2800,
+ beds: 4,
+ baths: 3,
+ yearBase: 1995,
+ desc: "Well-maintained family home in established neighborhood.",
+ condition: 4
+ },
+ {
+ label: "Fixer Upper",
+ canvassingStatus: "Hot Lead",
+ photos: ["https://images.unsplash.com/photo-1605276374104-dee2a0ed3cd6?w=800", "https://images.unsplash.com/photo-1600566753376-12c8ab7fb75b?w=800", "https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=800"],
+ baseValue: 320000,
+ areaBase: 2400,
+ beds: 3,
+ baths: 2,
+ yearBase: 1980,
+ desc: "Great potential, needs cosmetic updates.",
+ condition: 2
+ },
+ {
+ label: "Renovated Modern",
+ canvassingStatus: "Renovated",
+ photos: ["https://images.unsplash.com/photo-1600585154526-990dced4db0d?w=800", "https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=800", "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800"],
+ baseValue: 580000,
+ areaBase: 3200,
+ beds: 4,
+ baths: 3.5,
+ yearBase: 1990,
+ desc: "Recently renovated with modern aesthetics.",
+ condition: 5
+ }
+];
+
+const COMMERCIAL_VARIANTS = [
+ { name: "Legacy Retail Group", type: "Retail Mix", email: "leasing@legacyretail.com" },
+ { name: "Custer Office Park LLC", type: "Office Space", email: "mgmt@custeroffice.com" },
+ { name: "Plano Investments Inc", type: "Mixed Use", email: "contact@planoinvest.com" },
+ { name: "North Texas Prop Co", type: "Service Center", email: "info@ntxprops.com" }
+];
+
+const REAL_NAMES = [
+ "James Beauford", "Sarah Miller", "Robert Henderson", "Karen Smith", "Michael Chang",
+ "Patricia O'Neil", "Dr. Anish Patel", "Marcus Johnson", "Elena Rodriguez", "Greg Alston",
+ "Linda Grey", "Bill Thompson", "Jennifer Wu", "David Kowalski", "Omar Farooq",
+ "Lisa Chen", "Tom Baker", "Rachel Green", "Steve Martin", "Angela White"
+];
+
+const OCCUPATIONS = [
+ "Software Engineer", "Petroleum Engineer", "Teacher", "Nurse Practitioner",
+ "Small Business Owner", "Accountant", "Marketing Director", "retired",
+ "Attorney", "Real Estate Agent", "Sales Manager", "Consultant"
+];
+
+const EMPLOYERS_BY_OCCUPATION = {
+ "Software Engineer": ["Google", "Microsoft", "Toyota Connected", "Capital One", "Intuit"],
+ "Petroleum Engineer": ["ExxonMobil", "Chevron", "Pioneer Natural Resources", "Denbury"],
+ "Teacher": ["Plano ISD", "Frisco ISD", "Dallas ISD", "Richardson ISD"],
+ "Nurse Practitioner": ["Baylor Scott & White", "Medical City Plano", "Texas Health Resources"],
+ "Small Business Owner": ["Self Employed", "Local Boutique", "Consultancy Firm"],
+ "Accountant": ["Deloitte", "PwC", "EY", "KPMG", "Local CPA Firm"],
+ "Marketing Director": ["PepsiCo", "Toyota", "Frito-Lay", "Yum! Brands"],
+ "retired": ["N/A"],
+ "Attorney": ["Jones Day", "Baker Botts", "Norton Rose Fulbright", "Local Law Firm"],
+ "Real Estate Agent": ["Keller Williams", "Ebby Halliday", "Compass", "Remax"],
+ "Sales Manager": ["Oracle", "Salesforce", "AT&T", "Samsung"],
+ "Consultant": ["McKinsey", "BCG", "Accenture", "Freelance"]
+};
+
+// --- DATA GENERATION HELPERS ---
+
+function generatePolygon(lat, lng) {
+ const dLat = 0.0001; // ~11 meters
+ const dLng = 0.0001; // ~11 meters
+ return [
+ [lat + dLat, lng - dLng], // Top Left
+ [lat + dLat, lng + dLng], // Top Right
+ [lat - dLat, lng + dLng], // Bottom Right
+ [lat - dLat, lng - dLng], // Bottom Left
+ ];
+}
+
+function generateEmail(name, company = null) {
+ if (company) return company;
+ const domains = ["gmail.com", "yahoo.com", "outlook.com", "icloud.com"];
+ const domain = domains[Math.floor(Math.random() * domains.length)];
+ const parts = name.toLowerCase().split(' ');
+ const last = parts[parts.length - 1].replace(/[^a-z]/g, '');
+ const first = parts[0].replace(/[^a-z]/g, '');
+ return `${first}.${last}@${domain}`;
+}
+
+function generateProperties() {
+ return RAW_LOCATIONS.map((loc, index) => {
+ const isCommercial = loc.type === "Commercial" || loc.type === "Apartments";
+
+ const styleIndex = (index * 7) % PROPERTY_STYLES.length;
+ const style = isCommercial ? null : PROPERTY_STYLES[styleIndex];
+
+ let ownerName, ownerEmail, occupation, income, employerName;
+
+ if (isCommercial) {
+ const commVariant = COMMERCIAL_VARIANTS[index % COMMERCIAL_VARIANTS.length];
+ ownerName = commVariant.name;
+ ownerEmail = commVariant.email;
+ occupation = "Commercial Owner";
+ income = "$1M+";
+ employerName = "Self";
+ } else {
+ ownerName = REAL_NAMES[index % REAL_NAMES.length];
+ ownerEmail = generateEmail(ownerName);
+ occupation = OCCUPATIONS[index % OCCUPATIONS.length];
+
+ const potentialEmployers = EMPLOYERS_BY_OCCUPATION[occupation] || ["Self Employed"];
+ employerName = potentialEmployers[index % potentialEmployers.length];
+ income = ["$80k - $120k", "$120k - $180k", "$200k+", "$60k - $90k"][index % 4];
+ }
+
+ const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + (index % 15);
+ const sqft = isCommercial ? 12000 + (index * 500) : style.areaBase + (index * 132);
+ const value = isCommercial ? 2500000 + (index * 100000) : style.baseValue + (index * 12500);
+
+ const isRented = index % 3 === 0;
+ const tenantName = isRented ? (isCommercial ? "Multiple Commercial Tenants" : REAL_NAMES[(index + 5) % REAL_NAMES.length]) : null;
+ const tenantOcc = isRented ? (isCommercial ? "Retail/Office Mix" : OCCUPATIONS[(index + 3) % OCCUPATIONS.length]) : null;
+ const rentAmount = isRented ? Math.floor(value * 0.008 / 100) * 100 : null;
+
+ return {
+ id: index + 1,
+ center: [loc.lat, loc.lng],
+ polygon: generatePolygon(loc.lat, loc.lng),
+ canvassingStatus: isCommercial ? "Neutral" : style.canvassingStatus,
+ photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
+ propertyData: {
+ propertyId: `P-${2600 + index}`,
+ propertyAddress: loc.address,
+ propertyType: loc.type,
+ totalBuiltUpArea: sqft,
+ yearBuilt: builtYear,
+ numberOfBedrooms: isCommercial ? 0 : style.beds,
+ numberOfBathrooms: isCommercial ? 4 : style.baths,
+ numberOfParkingSpaces: isCommercial ? 25 : 2,
+ currentEstimatedMarketValue: value,
+ propertyTaxAssessmentValue: Math.floor(value * 0.85),
+ roofCondition: ["Good", "Fair", "Excellent", "Needs Repair"][index % 4],
+ renovationDescription: isCommercial ? "Commercial Maintenance" : style.desc,
+ renovationCost: Math.floor(value * 0.05),
+ lastMajorRenovationDate: "2020-05-15",
+ lastRoofRepairReplacementDate: "2018-02-10",
+ recentMajorRepairs: "Routine Maintenance",
+ currentlyRented: isRented,
+ currentTenantName: tenantName,
+ tenantOccupation: tenantOcc,
+ tenantAnnualIncome: isRented ? "$100k - $150k" : null,
+ currentMonthlyRentAmount: rentAmount,
+ leaseStartDate: isRented ? "2023-01-01" : null,
+ leaseEndDate: isRented ? "2025-01-01" : null,
+ ownerWillingToRent: index % 4 === 0,
+ expectedMonthlyRentAmount: Math.floor(value * 0.008 / 100) * 100,
+ rentalAvailabilityDate: null,
+ rentalTermsPreference: null,
+ schoolDistrict: "Plano ISD",
+ neighborhoodRating: 8 + (index % 3),
+ crimeRateIndex: "Low",
+ publicTransportationAccess: "Bus Line 204",
+ propertyConditionRating: isCommercial ? 4 : style.condition,
+ furnishingStatus: "Unfurnished",
+ notes: isCommercial ? "Prime commercial location" : style.desc,
+ latestPurchasePrice: Math.floor(value * 0.6),
+ latestPurchaseDate: "2015-06-20",
+ latestSaleListingDate: null,
+ latestSaleAskingPrice: null,
+ lotPlotSize: isCommercial ? "1.5 Acres" : "0.22 Acres"
+ },
+ ownerData: {
+ ownerId: `OWN-${200 + index}`,
+ fullName: ownerName,
+ primaryPhoneNumber: `972-555-${1000 + index}`,
+ secondaryPhoneNumber: null,
+ emailAddress: ownerEmail,
+ mailingAddress: loc.address,
+ occupation: occupation,
+ employerName: employerName,
+ annualIncomeRange: income,
+ creditScoreRange: "720+",
+ ownershipType: isCommercial ? "Corporate" : "Individual",
+ ownershipPercentage: "100%",
+ willingToRentProperty: false,
+ desiredMonthlyRentalPrice: null,
+ rentalConditions: null,
+ willingToSellProperty: index % 5 === 0,
+ desiredSellingPrice: Math.floor(value * 1.1),
+ minimumAcceptableSellingPrice: Math.floor(value * 1.05)
+ }
+ };
+ });
+}
+
+// --- NEW DATA (Users & Meetings) ---
+
+// --- NEW DATA (Users & Meetings) ---
+
+const MOCK_USERS = [
+ // Customers
+ { id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer' },
+ { id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' },
+ { id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
+
+ // Field Agents (5 Agents)
+ { id: 'e1', type: 'employee', empId: 'FA001', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Frank Agent' },
+ { id: 'e2', type: 'employee', empId: 'FA002', email: 'agent2@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fiona Field' },
+ { id: 'e3', type: 'employee', empId: 'FA003', email: 'agent3@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fred Flyer' },
+ { id: 'e4', type: 'employee', empId: 'FA004', email: 'agent4@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felicity Fast' },
+ { id: 'e5', type: 'employee', empId: 'FA005', email: 'agent5@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felix Fixer' },
+
+ // Admins (3 Admins)
+ { id: 'a1', type: 'employee', empId: 'ADM01', email: 'admin@plano.com', password: 'password', role: 'ADMIN', name: 'Adam Admin' },
+ { id: 'a2', type: 'employee', empId: 'ADM02', email: 'admin2@plano.com', password: 'password', role: 'ADMIN', name: 'Amanda Manager' },
+ { id: 'a3', type: 'employee', empId: 'ADM03', email: 'admin3@plano.com', password: 'password', role: 'ADMIN', name: 'Arthur Director' }
+];
+
+// Helper to generate meetings
+function generateMockMeetings() {
+ const agents = ['e1', 'e2', 'e3', 'e4', 'e5'];
+ const statuses = ['Scheduled', 'Rescheduled', 'Completed', 'Converted', 'Cancelled'];
+ const meetings = [];
+ let meetingIdCounter = 1;
+
+ agents.forEach(agentId => {
+ // Generate ~7 meetings per agent
+ for (let i = 0; i < 7; i++) {
+ const isPast = i < 4; // First 4 are past/completed/converted
+ const status = isPast
+ ? (Math.random() > 0.3 ? 'Completed' : (Math.random() > 0.5 ? 'Converted' : 'Cancelled')) // Past mix
+ : (Math.random() > 0.3 ? 'Scheduled' : 'Rescheduled'); // Future mix
+
+ // Date Logic
+ const date = new Date();
+ date.setDate(date.getDate() + (isPast ? -1 * (i + 1) * 2 : (i + 1) * 2)); // Spread out days
+
+ // Deal Value (only for relevant statuses)
+ let dealValue = null;
+ if (['Completed', 'Converted'].includes(status)) {
+ dealValue = Math.floor(Math.random() * (50000 - 5000) + 5000); // 5k to 50k
+ }
+
+ // Time Logic (09:00 to 16:00)
+ const hour = 9 + (i % 8);
+ const timeString = `${hour.toString().padStart(2, '0')}:00`;
+
+ meetings.push({
+ id: `m${meetingIdCounter++}`,
+ agentId: agentId,
+ customerName: REAL_NAMES[(meetingIdCounter * 3) % REAL_NAMES.length], // Pick random names
+ propertyId: `P-${2600 + (meetingIdCounter * 2)}`,
+ date: date.toISOString().split('T')[0],
+ time: timeString,
+ status: status,
+ dealValue: dealValue,
+ notes: status === 'Converted' ? 'Contract signed for full roof replacement.' :
+ status === 'Rescheduled' ? 'Client verified availability.' : 'Standard consultation.'
+ });
+ }
+ });
+
+ return meetings;
+}
+
+const MOCK_MEETINGS = generateMockMeetings();
+
+// --- CONTEXT SETUP ---
+
+const MockStoreContext = createContext();
+
+export const MockStoreProvider = ({ children }) => {
+ const [properties, setProperties] = useState([]);
+ const [users, setUsers] = useState(MOCK_USERS);
+ const [meetings, setMeetings] = useState(MOCK_MEETINGS);
+
+ // Initialize properties once
+ useEffect(() => {
+ const data = generateProperties();
+ setProperties(data);
+ }, []);
+
+ const updatePropertyStatus = (id, newStatus) => {
+ setProperties(prev => prev.map(p =>
+ p.id === id ? { ...p, canvassingStatus: newStatus } : p
+ ));
+ };
+
+ const addMeeting = (meeting) => {
+ setMeetings(prev => [...prev, { ...meeting, id: `m${prev.length + 1}` }]);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useMockStore = () => useContext(MockStoreContext);
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..94d86a6
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,53 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ background-color: #ffffff;
+ color: #1a1a1a;
+}
+
+@layer utilities {
+
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ -ms-overflow-style: none;
+ /* IE and Edge */
+ scrollbar-width: none;
+ /* Firefox */
+ }
+}
+
+/* Global Hidden Scrollbar (User Requested) */
+* {
+ -ms-overflow-style: none;
+ /* IE and Edge */
+ scrollbar-width: none;
+ /* Firefox */
+}
+
+*::-webkit-scrollbar {
+ display: none;
+}
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..43a561d
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import App from './App.jsx'
+import './index.css'
+import { MockStoreProvider } from './data/mockStore'
+import { AuthProvider } from './context/AuthContext'
+import { ThemeProvider } from './context/ThemeContext'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/src/pages/AdminSchedule.jsx b/src/pages/AdminSchedule.jsx
new file mode 100644
index 0000000..d8b2f87
--- /dev/null
+++ b/src/pages/AdminSchedule.jsx
@@ -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 (
+
+
+
+
+
+
+
+ All Active Appointments
+
+
+
+
+
+
+
+ Agent
+ Customer
+ Property
+ Date & Time
+ Status
+ Actions
+
+
+
+ {meetings.map((meeting) => (
+
+
+
+
+ {meeting.agentId}
+
+
Agent {meeting.agentId}
+
+
+ {meeting.customerName}
+ {meeting.propertyId}
+
+
+ {meeting.date}
+ {meeting.time}
+
+
+
+
+ {meeting.status}
+
+
+
+ Reschedule
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default AdminSchedule;
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..05a1d3f
--- /dev/null
+++ b/src/pages/Dashboard.jsx
@@ -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 (
+
+
+ {/* Ambient Background Glows */}
+
+
+
+
+
+
Dashboard Overview
+
Welcome back, here's what's happening with your territory.
+
+
+ {/* Real-time Clock Widget */}
+
+
+ {currentTime.toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour: 'numeric', minute: '2-digit', hour12: false })}
+
+
+ {currentTime.toLocaleDateString('en-US', { timeZone: 'America/Chicago', weekday: 'long', month: 'long', day: 'numeric' })} β’ Plano, TX
+
+
+
+
+ {/* Top Metrics Grid */}
+
+
+
+
+
+
+
+ {/* Main Content Grid */}
+
+
+ {/* Left Col: Hot Leads and Schedule */}
+
+
+ {/* Hot Leads Panel */}
+
+
+
+
Hot Leads
+
+ View Map
+
+
+
+
+ {hotLeads.length === 0 ? (
+
+ No hot leads active. Time to canvas!
+
+ ) : (
+ hotLeads.slice(0, 5).map(lead => (
+
+
+
+ {lead.id}
+
+
+
{lead.propertyData.propertyAddress.split(',')[0]}
+
{lead.propertyData.propertyType} β’ ${lead.propertyData.currentEstimatedMarketValue.toLocaleString()}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Split Schedule System */}
+
+
+ {/* Upcoming Meetings */}
+
+
+
+
+ {upcomingMeetings.length === 0 ? (
+
No upcoming meetings.
+ ) : (
+ upcomingMeetings.map(meeting => (
+
+ ))
+ )}
+
+
+
+
+ {/* Past / Completed Meetings */}
+
+
+
+
+ {pastMeetings.length === 0 ? (
+
No past meetings.
+ ) : (
+ pastMeetings.slice(0, 5).map(meeting => (
+
+ ))
+ )}
+
+
+
+
+
+
+ {/* Right Col: Weather Widget */}
+
+
+
+
+
+
+
+ );
+};
+
+// --- Sub Components ---
+
+const MeetingRow = ({ meeting, type }) => (
+
+
+
+
+ {new Date(meeting.date).toLocaleDateString('en-US', { month: 'short' })}
+ {new Date(meeting.date).getDate()}
+
+
+
{meeting.customerName}
+
+ {meeting.time}
+
+
+
+
+
+ {meeting.status}
+
+
+
+);
+
+const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) => (
+
+
+
+
+ {/* Decorative Neomorphic Inner Shadow */}
+
+
+);
+
+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
;
+ if (code > 800) return
;
+ if (code >= 500 && code < 600) return
;
+ if (code >= 200 && code < 300) return
;
+ return
;
+ };
+
+ const getMiniIcon = (code) => {
+ if (code === 800) return
;
+ if (code > 800) return
;
+ if (code >= 500) return
;
+ return
;
+ };
+
+ if (loading && !weather) return
Syncing OWM...
;
+
+ if (error) return (
+
+
+
+
Weather Unavailable
+
+ {error}
+
+ {(error.includes('401') || error.includes('Invalid API key')) && (
+
+ Please configure your VITE_OPENWEATHER_API_KEY in the .env file.
+
+ )}
+
+ Retry Connection
+
+
+
+ );
+
+ if (!weather || !weather.main) return
Weather Unavailable
;
+
+ return (
+ // Added text-zinc-900 to ensure text is visible in light mode
+
+
+ {/* Dynamic Weather Background Gradient - subtle */}
+
+
+
+
+
+
+
+
+ {weather.name || "Plano"}, {weather.sys?.country}
+
+
+
+
+
+
+
+
+ {Math.round(weather.main.temp)}
+ Β°F
+
+
+ Updated: {lastUpdated?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+
+ {getWeatherIcon(weather.weather[0].id)}
+
+
{weather.weather[0].description}
+
+ H: {Math.round(weather.main.temp_max)}Β°F L: {Math.round(weather.main.temp_min)}Β°F
+ Feels: {Math.round(weather.main.feels_like)}Β°F
+
+
+
+
+
+
+
+
+ Wind
+
+
{Math.round(weather.wind.speed)} mph
+
+
+
+
+ Humidity
+
+
{weather.main.humidity}%
+
+
+
+
+
Forecast (3-Hour Steps)
+
+ {forecast.map((item, index) => {
+ const date = new Date(item.dt * 1000);
+ const displayTime = date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
+
+ return (
+
+
{displayTime}
+
+ {getMiniIcon(item.weather[0].id)}
+
+
{Math.round(item.main.temp)}Β°
+
+ )
+ })}
+
+
+
+
+ );
+};
+
+export default Dashboard;
diff --git a/src/pages/Landing.jsx b/src/pages/Landing.jsx
new file mode 100644
index 0000000..123588f
--- /dev/null
+++ b/src/pages/Landing.jsx
@@ -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 (
+
+
+ {/* DITHER EFFECT - Fixed Overlay */}
+
+
+ {/* Nav */}
+
+
+
+
+
+ {!user ? (
+
+ Sign In
+
+ ) : (
+
+ Dashboard
+
+ )}
+
+
+
+
+ {/* HERO SECTION */}
+
+ {/* Background Image with Overlay */}
+
+
+
+
+
+
+
+ πΊπΈ
+ Proudly American β’ Made by Americans, For Americans
+
+
+
+ ROOFING
+ REVOLUTIONIZED
+
+
+
+ Automated drone inspections. Instant AI estimates.
+
+ Restoring your home's integrity with 20+ years of precision.
+
+
+
+
+ Get Free Estimate
+
+
+
+ See The Process
+
+
+
+ {/* Patriotic Discount Banner */}
+
+
+
+
Active Duty, Veterans & Seniors
+
Get an exclusive 15% Discount on all services.
+
+
+
+ {/* Satisfaction Badges */}
+
+
+
+
+ Licensed & Insured
+
+
+
+
+
+ {/* PROCESS SECTION (From Sky to Safety) */}
+
+
+
+
The Workflow
+
From Sky to Safety in 3 Steps.
+
+
+
+ {/* Step 1 */}
+
+
+
+
LIDAR_MAPPING_ACTIVE
+
+
+
01
+
The Scan.
+
+ Drones use LiDAR imagery 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.
+
+
+ 100% Contactless Inspection
+ 15-Minute Flight Time
+
+
+
+
+ {/* Step 2 */}
+
+
+
02
+
The Diagnosis.
+
+ This data is sent to our AI models , which instantly diagnose issues invisible to the naked eye. The algorithm highlights storm damage, wear patterns, and insulation failures, scoring them by urgency.
+
+
+ "Better, faster, and more accurate decisionsβeliminating human error."
+
+
+
+
+
+
+
+
Structure Integrity 76%
+
+
Moisture Level CRITICAL
+
+
+
+
+
+
+ {/* Step 3 */}
+
+
+
+
+
+
03
+
The Resolution.
+
+ 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.
+
+
+ Cost Efficient
+ Data-Backed
+
+
+
+
+
+
+
+ {/* COMPARISON SLIDER SECTION */}
+
+
+
+
+ {/* FACT CHECK & STATS */}
+
+
+
+
+
+
Why Risk The Wait?
+
Delaying roof repairs leads to exponential damage. Here are the facts.
+
+
+
+
+
40%
+
Insurance Denials
+
Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.
+
+
+
5yrs
+
Life Reduced
+
A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.
+
+
+
15m
+
Our Estimate Time
+
While others take days, we give you a quote before our drone even lands.
+
+
+
+
+
+ {/* TESTIMONIALS */}
+
+
+
Neighbors Who Love Us
+
+
+ {/* Senior/Veteran Testimonial */}
+
+
+
+
+
The Hendersons
+
Retired Veterans β’ Plano, TX
+
+
+
+ "The veteran discount was such a blessing. They found a leak our previous inspector missed completely. The drone technology was fascinating to watch!"
+
+
+
+
+
JD
+
+
James D.
+
Homeowner β’ Frisco, TX
+
+
+
+ "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."
+
+
+
+
+
SM
+
+
Sarah M.
+
Investor β’ Dallas, TX
+
+
+
+ "Managing 5 properties is hard. LynkedUpPro makes it easy. I can see the roof status of all my houses on my dashboard. Incredible tool."
+
+
+
+
+
+
+
+ );
+};
+
+export default Landing;
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx
new file mode 100644
index 0000000..de3cd76
--- /dev/null
+++ b/src/pages/Login.jsx
@@ -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 (
+
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 */}
+
+
+ {/* Button Background (Glass/Neo) */}
+
+
+ {/* Content */}
+
+ {children}
+
+
+ );
+};
+
+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 (
+
+ {/* Subtle Ambient Background */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome Back
+
+
Sign in to your Plano Realty account
+
+
+ {/* Neo-Toggle */}
+
+ 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'}`}
+ >
+
+ Customer
+
+ 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'}`}
+ >
+
+ Employee
+
+
+
+
+
+ {/* Demo Quick Links */}
+
+
Quick Demo Access
+
+ 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
+ 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
+ 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
+
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/src/pages/Maps.jsx b/src/pages/Maps.jsx
new file mode 100644
index 0000000..d805d02
--- /dev/null
+++ b/src/pages/Maps.jsx
@@ -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 (
+
+ {status}
+
+ );
+};
+
+// 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 (
+
+ {/*
+ - 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
+ */}
+
+
+
+
+ {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 (
+ {
+ L.DomEvent.stopPropagation(e); // Prevent map click
+ onSelect(item);
+ },
+ }}
+ />
+ );
+ })}
+
+ );
+};
+
+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 (
+
+
+ {formData[field] || "-"}
+
+
+ );
+ }
+
+ if (locked) {
+ return (
+
+
+ {placeholder || "Not Applicable"}
+
+
+ );
+ }
+
+ return (
+
+ {options ? (
+ 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 => (
+
+ {opt}
+
+ ))}
+
+ ) : (
+ 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"
+ />
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+ {/* Pattern */}
+
+
+
+
+
+
+
+
+ {isCreating ? New Entry : }
+
+
{loadingAddr ? "Fetching Address..." : address.split(',')[0]}
+
{loadingAddr ? "Locating..." : address}
+
+
+
+ {/* Body Content */}
+
+
+ {loadingAddr ? (
+
+
+ Resolving Location...
+
+ ) : (
+ <>
+ {/* Status Selector */}
+ {isEditing && (
+
+
Canvassing Status
+
+ {["Neutral", "Hot Lead", "Customer", "Not Interested"].map(s => (
+ 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}
+ ))}
+
+
+ )}
+
+ {/* SECTION 1: BASIC PROPERTY INFO */}
+
+
+
Property Basics
+
+
+
+
+ {/* SECTION 2: VALUES & CONDITION */}
+
+
+
Value & Condition
+
+
+
+
+
+
+
+
+
+ {/* SECTION 3: RENTAL STATUS (Conditional) */}
+
+
+
+
+
+ {formData.isRented === 'Yes' ? (
+
+ ) : (
+
+
+ {formData.ownerWillingToRent !== 'No' && (
+
+ )}
+
+ )}
+
+
+ {/* SECTION 4: OWNER INTENT (Conditional) */}
+
+
+
Recall & Sales Intent
+
+
+
+
+
+
+
+ {formData.willingToSell === 'Yes' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* SECTION 5: OWNER CONTACT */}
+
+
+
Owner Contact
+
+
+
+
+
+
+
+
+
+ {/* SECTION 6: NOTES */}
+
+
+
Field Notes
+
+ {isEditing ? (
+
+ >
+ )}
+
+
+ {/* Footer */}
+
+ {isEditing ? (
+
+ {isCreating ? "Create Entry" : "Save Changes"}
+
+ ) : (
+ 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">
+ Edit Details
+
+ )}
+
+
+
+ );
+};
+
+const InputGroup = ({ label, children }) => (
+
+);
+
+
+// --- 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 (
+
+
+
+ { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
+ data={selectedProperty}
+ newLocation={newPropertyLocation}
+ onSave={handleSave}
+ loadingAddr={loadingAddr}
+ />
+
+ );
+}
+
+export default Maps;
diff --git a/src/pages/NotFound.jsx b/src/pages/NotFound.jsx
new file mode 100644
index 0000000..50ce549
--- /dev/null
+++ b/src/pages/NotFound.jsx
@@ -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 (
+
+
+ {/* Background Texture */}
+
+
+ {/* Content */}
+
+
+
+
+
+
+ 404
+
+
+
Roof Not Found
+
+
+ It seems you've wandered off the blueprint. The property or page you are looking for doesn't exist or has been demolished.
+
+
+
+
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"
+ >
+
+ Go Back
+
+
+
+
+ Return Home
+
+
+
+
+ {/* Footer */}
+
+ LynkedUpPro Infrastructure
+
+
+ );
+};
+
+export default NotFound;
diff --git a/src/utils/logger.js b/src/utils/logger.js
new file mode 100644
index 0000000..d11e81d
--- /dev/null
+++ b/src/utils/logger.js
@@ -0,0 +1,43 @@
+/**
+ * Centralized Logger Service
+ * helps standardise logging across the application.
+ */
+
+const LOG_LEVELS = {
+ INFO: 'info',
+ WARN: 'warn',
+ ERROR: 'error',
+};
+
+// Toggle this to false in production to suppress logs
+const IS_DEV = import.meta.env.DEV;
+
+const formatMessage = (level, message, context) => {
+ const timestamp = new Date().toISOString();
+ return {
+ timestamp,
+ level,
+ message,
+ context,
+ };
+};
+
+export const logger = {
+ info: (message, context = {}) => {
+ if (IS_DEV) {
+ console.log(`%c[INFO] ${message}`, 'color: #3b82f6; font-weight: bold;', context);
+ }
+ },
+
+ warn: (message, context = {}) => {
+ console.warn(`%c[WARN] ${message}`, 'color: #f59e0b; font-weight: bold;', context);
+ },
+
+ error: (message, error = null, context = {}) => {
+ console.error(`%c[ERROR] ${message}`, 'color: #ef4444; font-weight: bold;', {
+ error,
+ ...context,
+ });
+ // In a real app, you might send this to Sentry/LogRocket here
+ },
+};
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..9121d4e
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..8b0f57b
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})