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
+5
View File
@@ -0,0 +1,5 @@
# AI Integration (Groq)
VITE_GROQ_API_KEY=Your-API-Key
# Weather Widget (OpenWeatherMap)
VITE_OPENWEATHER_API_KEY=Your-API-Key
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Custom ignores
*.txt
*.md
!README.md
!data_schema.txt
+112
View File
@@ -0,0 +1,112 @@
# Plano Realty CRM - Field Service & Sales Platform
**Plano Realty CRM** is a next-generation Geospatial CRM designed for roofing professionals in Plano, Texas. It specifically targets the coordination between field agents and customers, leveraging AI and real-time data to streamline the sales and inspection process.
## 🚀 Product Overview
- **Purpose**: To replace outdated spreadsheet tracking with an interactive, map-based interface for managing roofing leads and inspections.
- **Core Functionality**:
- **Interactive Maps**: Visual territory management with property polygons.
- **AI Concierge**: A chatbot (powered by Groq/Qwen) that schedules meetings and answers context-aware questions.
- **Dashboard**: Real-time metrics, weather integration (OpenWeatherMap), and schedule management.
- **Role-Based Access**: Distinct interfaces for **Field Agents**, **Admins**, and **Customers**.
## 🛠 Implementation Details
The system is built as a **Single Page Application (SPA)** using:
- **Frontend Framework**: React 18 + Vite
- **Styling**: Tailwind CSS (Dark Mode Native) + Glassmorphism UI
- **State Management**: React Context API (`AuthContext`, `MockStore`)
- **AI Integration**: Groq SDK (running `qwen-2.5-32b`) for natural language processing.
- **Maps**: Custom SVG/Canvas implementations for property visualization.
- **Animations**: GSAP (GreenSock) for high-performance interactions.
## 📂 Project Structure
```text
/src
├── assets/ # Static images and icons
├── components/ # Reusable UI components
│ ├── Chatbot.jsx # AI Floating Widget
│ ├── Layout.jsx # App Sidebar & Shell
│ ├── SpotlightCard.jsx # Premium Card UI with Glow
│ └── ...
├── config/ # Environment variables
│ └── env.js # Centralized config loader
├── context/ # Global State
│ ├── AuthContext.jsx # User Authentication Logic
│ └── ThemeContext.jsx # Dark Mode Management
├── data/ # Data Layer
│ └── mockStore.jsx # Mock Data Generator (Users, Meetings, Properties)
├── pages/ # Route Views
│ ├── Dashboard.jsx # Agent KPI View
│ ├── Landing.jsx # Public Sales Page
│ ├── Login.jsx # Authentication Pge
│ └── Maps.jsx # Territory Map View
└── utils/ # Helpers
└── logger.js # Structured Logging
```
## ⚡ Running the Project
1. **Prerequisites**: Node.js (v18+) and `pnpm`.
2. **Installation**:
```bash
pnpm install
```
3. **Environment Setup**:
- Create a `.env` file in the root.
- Add keys:
```env
VITE_GROQ_API_KEY=your_key_here
VITE_OPENWEATHER_API_KEY=your_key_here
```
4. **Start Dev Server**:
```bash
pnpm run dev
```
Access the app at `http://localhost:5173`.
## 📝 File Responsibilities
### `src/data/mockStore.jsx`
The heart of the application's data layer. Since there is no backend, this file generates realistic seed data.
- **`generateProperties()`**: Creates ~60 property records with geospatial coordinates, ownership details, and styles (Commercial vs Residential).
- **`generateMockMeetings()`**: Simulates agent schedules, ensuring a mix of past (completed) and future (scheduled) appointments.
### `src/components/Chatbot.jsx`
Handles all AI interactions.
- **Input**: User text + Current User Context + Meeting Schedule.
- **Processing**: Sends prompt to Groq API with a custom system prompt ensuring "Americana" branding.
- **Output**: Renders Markdown responses and handles structured `CALLBACK_REQUEST` JSON for scheduling.
### `src/context/AuthContext.jsx`
Manages user sessions.
- **`login(identifier, password, type)`**: Validates credentials against `MOCK_USERS`.
- **`user` object**: Contains `role`, `name`, `id`, and permissions.
## 📚 Function Documentation
### `useMockStore: addMeeting(meeting)`
Adds a new meeting to the global state.
- **Inputs**:
- `meeting` (Object): `{ agentId, customerName, time, date, propertyId, status }`
- **Returns**: `void`
- **Side Effect**: Updates the `meetings` array and triggers a re-render of the Dashboard and Chatbot context.
### `config.isDemoMode(key)`
Checks if the system should run in simulated demo mode.
- **Inputs**: `key` (String) - The API key from environment variables.
- **Returns**: `Boolean` - `true` if key is missing or is the default placeholder.
## 🔌 APIs & Integrations
| Service | Purpose | Key Variable |
| :--- | :--- | :--- |
| **Groq Cloud** | LLM Inference (Chatbot) | `VITE_GROQ_API_KEY` |
| **OpenWeather** | Dashboard Weather Widget | `VITE_OPENWEATHER_API_KEY` |
---
**Created by Antigravity for Plano Realty CRM**
+88
View File
@@ -0,0 +1,88 @@
Data Entities:
TYPE A: PROPERTY NODE
1.1 Basic Property Details
- Property ID [Mandatory]
- Property Address [Mandatory] (Use real-sounding TX addresses)
- Property Type [Mandatory] (House / Apartment / Villa / Condo / Commercial)
- Total Built-up Area [Mandatory] (sq ft)
- Lot/Plot Size (Optional)
- Year Built [Mandatory]
- Number of Bedrooms [Mandatory]
- Number of Bathrooms [Mandatory]
- Number of Parking Spaces (Optional)
1.2 Transaction & Market Information
- Latest Purchase Date (Optional)
- Latest Purchase Price (Optional)
- Latest Sale Listing Date (Optional)
- Latest Sale Asking Price (Optional)
- Current Estimated Market Value [Mandatory]
- Property Tax Assessment Value [Mandatory]
1.3 Renovation & Maintenance History
- Last Major Renovation Date (Optional)
- Renovation Description (Optional)
- Renovation Cost (Optional)
- Last Roof Repair/Replacement Date (Optional)
- Roof Condition [Mandatory] (Excellent / Good / Fair / Needs Repair)
- Recent Major Repairs (Optional)
1.4 Rental Status
- Currently Rented [Mandatory] (Yes / No)
- Current Tenant Name (Optional - if rented)
- Current Monthly Rent Amount (Optional)
- Lease Start Date (Optional)
- Lease End Date (Optional)
- Security Deposit Amount (Optional)
If Not Rented:
- Owner Willing to Rent [Mandatory]
- Expected Monthly Rent Amount (Optional)
- Rental Availability Date (Optional)
- Rental Terms Preference (Optional)
1.5 Location & Neighborhood
- School District [Mandatory]
- Distance to Major Amenities (Optional)
- Neighborhood Rating [Mandatory] (1-10)
- Crime Rate Index (Optional)
- Public Transportation Access (Optional)
1.6 Optional Enhancements
- Property Condition Rating [Mandatory] (15)
- Furnishing Status (Optional)
- Notes / Remarks (Optional)
- Last Updated Date [Mandatory]
---
TYPE B: OWNER NODE
2.1 Personal Details
- Owner ID [Mandatory]
- Full Name [Mandatory]
- Date of Birth (Optional)
2.2 Contact Information
- Primary Phone Number [Mandatory]
- Secondary Phone Number (Optional)
- Email Address [Mandatory]
- Mailing Address (Optional - if different from property)
2.3 Professional & Financial Profile
- Occupation [Mandatory]
- Employer Name (Optional)
- Annual Income Range (Optional)
- Credit Score Range (Optional)
- Ownership Type [Mandatory] (Individual / Joint / Corporate / Trust)
- Ownership Percentage (Optional)
2.4 Owner Intent & Preferences
- Willing to Rent Property [Mandatory]
- Desired Monthly Rental Price (Optional)
- Rental Conditions/Requirements (Optional)
- Willing to Sell Property [Mandatory]
- Desired Selling Price (Optional)
- Minimum Acceptable Selling Price (Optional)
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plano Realty CRM</title>
<meta name="description"
content="Next-Gen Real Estate Management Platform with AI Insights and Interactive Mapping." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://plano-realty-crm.vercel.app/" />
<meta property="og:title" content="Plano Realty CRM - Future of Real Estate" />
<meta property="og:description"
content="Experience the future of property management with our AI-driven insights, interactive 3D mapping, and seamless field agent coordination." />
<meta property="og:image" content="https://images.unsplash.com/photo-1560518883-ce09059eeffa?q=80&w=1200" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://plano-realty-crm.vercel.app/" />
<meta property="twitter:title" content="Plano Realty CRM - Future of Real Estate" />
<meta property="twitter:description"
content="Experience the future of property management with our AI-driven insights, interactive 3D mapping, and seamless field agent coordination." />
<meta property="twitter:image" content="https://images.unsplash.com/photo-1560518883-ce09059eeffa?q=80&w=1200" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+5473
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "plano-realty-crm",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"groq-sdk": "^0.37.0",
"gsap": "^3.14.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.563.0",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}
+3387
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Landing from './pages/Landing';
import Dashboard from './pages/Dashboard';
import Maps from './pages/Maps';
import AdminSchedule from './pages/AdminSchedule';
import ErrorBoundary from './components/ErrorBoundary';
import { Toaster } from 'sonner';
import NotFound from './pages/NotFound';
// Protected Route Component
const ProtectedRoute = ({ children, allowedRoles }) => {
const { user, isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
// If user is employee but wrong role, go to their default dashboard
// For now, just redirect to home or show unauthorized
return <Navigate to="/" replace />;
}
return children;
};
function App() {
return (
<ErrorBoundary>
<Toaster position="top-right" richColors closeButton expand={true} />
<Routes>
<Route element={<Layout />}>
{/* Public Routes */}
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
{/* Protected Field Agent Routes */}
<Route
path="/emp/fa/dashboard"
element={
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/emp/fa/maps"
element={
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
<Maps />
</ProtectedRoute>
}
/>
{/* Protected Admin Routes */}
<Route
path="/admin/schedule"
element={
<ProtectedRoute allowedRoles={['ADMIN']}>
<AdminSchedule />
</ProtectedRoute>
}
/>
</Route>
{/* Catch all */}
<Route path="*" element={<NotFound />} />
</Routes>
</ErrorBoundary>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+306
View File
@@ -0,0 +1,306 @@
import React, { useState, useEffect, useRef } from 'react';
import Groq from 'groq-sdk';
import ReactMarkdown from 'react-markdown';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { MessageCircle, Send, X, Minimize2, Maximize2, Loader2, Calendar } from 'lucide-react';
import { logger } from '../utils/logger';
import { toast } from 'sonner';
import { config } from '../config/env';
const groq = new Groq({
apiKey: config.groqApiKey,
dangerouslyAllowBrowser: true // Allowed for client-side demo
});
const SYSTEM_PROMPT = `
You are the AI Concierge for "LynkedUpPro", the pinnacle of roofing excellence with over two decades of mastery in the field.
Your mission is to guide clients through our seamless, automated roof damage detection and restoration services with elegance and expertise.
COMPANY HIGHLIGHTS:
- **Experience**: Over 20 years of unrivaled craftsmanship.
- **Special Offer**: The first 3 diagnostic examination visits are completely **COMPLIMENTARY**.
- **Efficiency**: We provide detailed estimations in a "jiffy" — swift and precise.
- **Value**: We pride ourselves on delivering superior results that outshine the competition, all while remaining cost-effective.
- **Discounts**: We offer exclusive price reductions for **Veterans and Seniors** as a token of our gratitude.
- **Pricing Policy**: Transparency is our hallmark. While our initial diagnostics are free (first 3 visits), our restoration services are priced with integrity, tailored strictly to the extent of damage and the surface area requiring attention.
- **Contact**: (555) 012-3456
GUIDELINES:
1. **Tone**: Use eloquent, "flowery", and professional language. Be persuasive yet sincere. (e.g., instead of "we fix roofs", say "we restore the integrity of your shelter").
2. **Context Awareness**: Do not overwhelm the user. Gauge their intent. If they ask about price, explain the free exams vs. work costs. If they ask about speed, mention our "estimation in a jiffy".
3. **For General Inquiries**: Weave in our 20+ years of heritage and our commitment to excellence.
4. **For Callback Requests**:
- **IF GUEST**: Gently invite them to Log In or Sign Up to unlock our seamless scheduling experience.
- **IF LOGGED IN**: Request their preferred **Phone Number** and **Time Slot** to arrange a consultation. Extract the **Customer Name** from the conversation context (e.g., if they asked about "Greg Alston").
- **ACTION**: Once details are confirmed, output this JSON object ONLY at the very end:
:::CALLBACK_REQUEST{"name": "...", "phone": "...", "time": "HH:MM"}:::
`;
const Chatbot = () => {
const { user } = useAuth();
const { addMeeting, meetings } = useMockStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isDemoMode, setIsDemoMode] = useState(false);
const [messages, setMessages] = useState([
{ role: 'assistant', content: 'Hello! Welcome to LynkedUpPro. How can I help you with your roofing needs today?' }
]);
const messagesEndRef = useRef(null);
// Initial check for API Key
useEffect(() => {
if (config.isDemoMode(config.groqApiKey)) {
setIsDemoMode(true);
setMessages(prev => [...prev, { role: 'system', content: '⚠️ Demo Mode: Default API Key detected. Responses will be simulated.' }]);
}
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Update greeting and context when user changes
useEffect(() => {
const greeting = user
? `Welcome back to LynkedUpPro, ${user.name}! I see you're logged in. How can I assist you with your roofing projects today?`
: 'Hello! Welcome to LynkedUpPro. How can I help you with your roofing needs today?';
setMessages([{ role: 'assistant', content: greeting }]);
}, [user]);
const handleSend = async () => {
if (!input.trim()) return;
const newMessages = [...messages, { role: 'user', content: input }];
setMessages(newMessages);
setInput('');
setIsLoading(true);
try {
if (isDemoMode) {
// ... existing demo logic ...
setTimeout(() => {
const responses = [
"I can help with that! At LynkedUpPro, we've been in business for 20+ years. I can't access real-time data in demo mode, but I can help you navigate.",
"We specialize in automated damage detection. Please call us at (555) 012-3456 for a real quote!",
"Would you like to schedule a callback? Please log in first."
];
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
setMessages(prev => [...prev, { role: 'assistant', content: randomResponse }]);
setIsLoading(false);
}, 1000);
return;
}
// --- REAL AI REQUEST ---
// Construct System Context with User Details and DATA
let systemContext = SYSTEM_PROMPT;
if (user) {
// 1. Basic User Info
systemContext += `\n\nCURRENT USER CONTEXT:
- Name: ${user.name}
- Role: ${user.role} (Type: ${user.type})
- ID: ${user.id}
- Email: ${user.email || 'Not provided'}`;
// 2. Inject Meeting/Schedule Data (If Agent or Admin)
const userMeetings = user.role === 'ADMIN'
? meetings // Admins see ALL meetings (simplified for chatbot context, maybe limit to recent?)
: meetings.filter(m => m.agentId === user.id);
if (userMeetings.length > 0) {
const scheduleSummary = userMeetings.map(m =>
`- [${m.status}] ${m.date} @ ${m.time}: ${m.customerName} (Prop: ${m.propertyId})${m.dealValue ? ` | Value: $${m.dealValue.toLocaleString()}` : ''}`
).join('\n');
systemContext += `\n\nUSER'S SCHEDULE / DEALS (Use this to answer questions about meetings, status, and conversions):
${scheduleSummary}`;
} else {
systemContext += `\n\nUSER'S SCHEDULE: No meetings found.`;
}
} else {
systemContext += `\n\nCURRENT USER CONTEXT: Guest (Not Logged In)`;
}
const chatCompletion = await groq.chat.completions.create({
messages: [
{ role: 'system', content: systemContext },
...newMessages
],
model: 'qwen/qwen3-32b',
temperature: 0.7,
max_tokens: 1024,
});
let responseContent = chatCompletion.choices[0]?.message?.content || "I'm having trouble connecting right now.";
// Filter out <think> blocks if present
responseContent = responseContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
// Check for Function Call Pattern
const callbackMatch = responseContent.match(/:::CALLBACK_REQUEST({.*?}):::/);
if (callbackMatch) {
const jsonStr = callbackMatch[1];
try {
const data = JSON.parse(jsonStr);
// Create Meeting / Callback
const mockDate = new Date();
mockDate.setDate(mockDate.getDate() + 1); // Next day default
const meeting = {
agentId: user ? user.id : 'e1', // Use logged in user ID
customerName: data.name || 'Potential Client',
propertyId: 'Callback Request',
date: mockDate.toISOString().split('T')[0],
time: data.time || '10:00',
status: 'Pending',
notes: `Callback requested for ${data.phone}`
};
addMeeting(meeting);
// Clean the response to hide the JSON
responseContent = responseContent.replace(callbackMatch[0], '').trim();
if (!responseContent) responseContent = "I've successfully scheduled a callback for you! We will contact you shortly.";
else responseContent += "\n\n✅ Callback scheduled successfully!";
} catch (e) {
console.error("Failed to parse callback JSON", e);
}
}
setMessages(prev => [...prev, { role: 'assistant', content: responseContent }]);
} catch (error) {
logger.error("Groq API Error", error);
let errorMsg = "Sorry, I'm having trouble connecting to the Groq server.";
if (error.message) {
if (error.message.includes('401')) {
errorMsg += " (Error 401: Invalid API Key)";
toast.error("AI Configuration Error", { description: "Invalid API Key." });
}
else if (error.message.includes('404')) {
errorMsg += " (Error 404: Model not found)";
toast.error("AI Model Error", { description: "Model not found." });
}
else errorMsg += ` (${error.message})`;
}
setMessages(prev => [...prev, { role: 'assistant', content: errorMsg }]);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') handleSend();
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-gradient-to-tr from-blue-600 to-cyan-500 rounded-full shadow-xl shadow-blue-500/30 flex items-center justify-center text-white hover:scale-110 transition-transform z-50"
>
<div className="absolute top-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white animate-pulse"></div>
<MessageCircle size={28} />
</button>
);
}
return (
<div className={`fixed bottom-6 right-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl z-50 transition-all duration-300 overflow-hidden border border-gray-100 dark:border-zinc-800 flex flex-col ${isMinimized ? 'w-72 h-14' : 'w-80 md:w-96 h-[500px]'}`}>
{/* Header */}
<div className="bg-slate-900 dark:bg-black p-4 flex items-center justify-between shrink-0 cursor-pointer" onClick={() => !isMinimized && setIsMinimized(!isMinimized)}>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<h3 className="text-white font-bold text-sm">Plano AI Assistant</h3>
</div>
<div className="flex items-center space-x-1">
<button onClick={(e) => { e.stopPropagation(); setIsMinimized(!isMinimized); }} className="p-1 text-slate-400 hover:text-white">
{isMinimized ? <Maximize2 size={14} /> : <Minimize2 size={14} />}
</button>
<button onClick={(e) => { e.stopPropagation(); setIsOpen(false); }} className="p-1 text-slate-400 hover:text-red-400">
<X size={16} />
</button>
</div>
</div>
{/* Messages */}
{!isMinimized && (
<>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50 dark:bg-zinc-950/50">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-3 rounded-2xl text-sm ${msg.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 shadow-sm text-slate-700 dark:text-zinc-200 rounded-bl-none'
}`}>
{msg.role === 'system' ? (
<em className="text-xs opacity-70">{msg.content}</em>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed break-words text-inherit">
<ReactMarkdown
components={{
ul: ({ node, ...props }) => <ul className="list-disc ml-4 space-y-1 my-2" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal ml-4 space-y-1 my-2" {...props} />,
li: ({ node, ...props }) => <li className="pl-1" {...props} />,
p: ({ node, ...props }) => <p className="mb-2 last:mb-0" {...props} />,
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
a: ({ node, ...props }) => <a className="underline hover:text-blue-200" target="_blank" rel="noopener noreferrer" {...props} />
}}
>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 p-3 rounded-2xl rounded-bl-none shadow-sm">
<Loader2 size={16} className="animate-spin text-blue-500" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
className="flex-1 bg-transparent text-sm focus:outline-none text-slate-900 dark:text-white"
/>
<button
onClick={handleSend}
disabled={!input.trim()}
className={`p-1.5 rounded-full transition-colors ${input.trim() ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-300 dark:bg-zinc-600 text-slate-500 dark:text-zinc-400'}`}
>
<Send size={14} />
</button>
</div>
<div className="text-center mt-2">
<p className="text-[10px] text-slate-400 dark:text-zinc-500">Powered by Groq LPU & Qwen</p>
</div>
</div>
</>
)}
</div>
);
};
export default Chatbot;
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useRef, useEffect } from 'react';
import { MoveHorizontal } from 'lucide-react';
export const ComparisonSlider = () => {
const [sliderPos, setSliderPos] = useState(50);
const containerRef = useRef(null);
const handleMove = (e) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
setSliderPos(percentage);
};
const handleTouchMove = (e) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const touch = e.touches[0];
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
setSliderPos(percentage);
};
return (
<div className="w-full max-w-5xl mx-auto my-20">
<h2 className="text-4xl font-black text-center mb-10 text-zinc-900 dark:text-white">
The <span className="text-zinc-400">Old</span> vs The <span className="text-blue-500">LynkedUp</span> Way
</h2>
<div
ref={containerRef}
className="relative w-full h-[400px] md:h-[600px] rounded-3xl overflow-hidden cursor-ew-resize shadow-2xlSelect-none group"
onMouseMove={handleMove}
onTouchMove={handleTouchMove}
>
{/* AFTER IMAGE (New Way) - Background */}
<div className="absolute inset-0 w-full h-full">
<img
src="/src/assets/images/compare-new.png"
alt="The New Way"
className="w-full h-full object-cover"
/>
<div className="absolute top-10 right-10 flex flex-col items-end space-y-4 select-none">
<span className="text-xl md:text-2xl font-black text-white bg-black/50 backdrop-blur-md px-4 py-2 rounded-xl border border-blue-500/50">
Our Way
</span>
<div className="text-right space-y-2">
{[
"15-Minute Drone Scan",
"Zero Safety Risk",
"AI + Thermal Imaging",
"~30% Lower Costs"
].map((text, i) => (
<div key={i} className="flex items-center justify-end space-x-2 bg-black/40 backdrop-blur-sm px-3 py-1 rounded-lg border border-blue-500/20">
<span className="text-sm md:text-base font-bold text-white">{text}</span>
<div className="w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</div>
</div>
))}
</div>
</div>
</div>
{/* BEFORE IMAGE (Old Way) - Clipped Overlay */}
<div
className="absolute inset-0 w-full h-full overflow-hidden border-r-4 border-white shadow-2xl"
style={{ width: `${sliderPos}%` }}
>
<img
src="/src/assets/images/compare-old.png"
alt="The Old Way"
className="w-full h-full object-cover max-w-none grayscale sepia-[0.3]"
style={{ width: containerRef.current?.offsetWidth }}
/>
{/* Overlay Text visible only when this side is dominant */}
<div className="absolute top-10 left-10 flex flex-col items-start space-y-4 select-none" style={{ opacity: Math.max(0, (sliderPos - 10) / 40) }}>
<span className="text-xl md:text-2xl font-black text-zinc-200 bg-black/60 backdrop-blur-md px-4 py-2 rounded-xl border border-red-500/50">
The Old Way
</span>
<div className="text-left space-y-2">
{[
"Dangerous Ladders & Falls",
"Guesswork Based Estimates",
"Slow Turnaround Times",
"Hidden Material Costs"
].map((text, i) => (
<div key={i} className="flex items-center space-x-2 bg-black/50 backdrop-blur-sm px-3 py-1 rounded-lg border border-red-500/20">
<div className="w-5 h-5 rounded-full bg-red-500/80 flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
</div>
<span className="text-sm md:text-base font-bold text-zinc-300">{text}</span>
</div>
))}
</div>
</div>
</div>
{/* Slider Handle */}
<div
className="absolute top-0 bottom-0 w-1 bg-white cursor-ew-resize hidden md:flex items-center justify-center shadow-[0_0_20px_rgba(0,0,0,0.5)]"
style={{ left: `${sliderPos}%` }}
>
<div className="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-xl transform transition-transform group-hover:scale-110">
<MoveHorizontal className="text-black" size={24} />
</div>
</div>
{/* Mobile Slider Handle (Always visible comparison line) */}
<div
className="absolute top-0 bottom-0 w-1 bg-white/50 md:hidden"
style={{ left: `${sliderPos}%` }}
/>
{/* Instructions Overlay (Fades out on interaction) */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/80 text-sm font-medium bg-black/40 px-4 py-2 rounded-full backdrop-blur-sm pointer-events-none">
Drag to compare
</div>
</div>
</div>
);
};
+60
View File
@@ -0,0 +1,60 @@
import React, { Component } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
console.error("Uncaught error:", error, errorInfo);
}
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
// Custom Fallback UI
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex flex-col items-center justify-center p-6 text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
<AlertTriangle size={32} className="text-red-500" />
</div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white mb-2 tracking-tight">
Something went wrong
</h1>
<p className="text-zinc-500 dark:text-zinc-400 max-w-md mb-8">
We encountered an unexpected error. Our team has been notified. Please try refreshing the page.
</p>
<div className="p-4 bg-zinc-100 dark:bg-white/5 rounded-xl border border-zinc-200 dark:border-white/10 max-w-lg w-full mb-8 text-left font-mono text-xs text-red-500 overflow-auto max-h-40">
{this.state.error && this.state.error.toString()}
</div>
<button
onClick={this.handleReload}
className="flex items-center space-x-2 px-6 py-3 bg-zinc-900 dark:bg-white text-white dark:text-black font-bold rounded-full hover:opacity-90 transition-opacity"
>
<RefreshCw size={18} />
<span>Reload Application</span>
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+165
View File
@@ -0,0 +1,165 @@
import React, { useRef, useState } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare } from 'lucide-react';
import Chatbot from './Chatbot';
// Rainbow Sidebar Item Component
const SidebarItem = ({ to, icon: Icon, label }) => {
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 (
<NavLink
to={to}
ref={divRef}
onMouseMove={handleMouseMove}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={({ isActive }) =>
`relative flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-300 group overflow-hidden mb-1 ${isActive
? 'text-zinc-900 dark:text-white bg-black/5 dark:bg-white/5 shadow-sm dark:shadow-black/10'
: 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white bg-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`
}
>
{/*
Rainbow Border Layer
- Only visible on Hover (opacity controlled by state)
*/}
<div
className='pointer-events-none absolute -inset-px opacity-0 transition duration-300 z-0'
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%)`,
}}
/>
{/* Inner Mask (The "Button" surface) */}
<div className={`absolute inset-[1px] rounded-[11px] z-0 pointer-events-none transition-colors duration-300 ${opacity > 0 ? 'bg-zinc-50 dark:bg-[#121214]' : 'bg-transparent'
}`} />
{/* Content (Z-index to sit above the mask) */}
<div className="relative z-10 flex items-center space-x-3 w-full">
<Icon size={18} className="transition-transform group-hover:scale-110 duration-300" />
<span className="font-medium text-sm tracking-wide">{label}</span>
</div>
{/* Active Indicator Dot */}
<div className={({ isActive }) => `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'}`} />
</NavLink>
);
};
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 <Outlet />;
}
return (
<div className="flex h-screen bg-zinc-50 dark:bg-[#050505] text-zinc-900 dark:text-white overflow-hidden font-sans selection:bg-blue-500/20 dark:selection:bg-white/20 transition-colors duration-300">
{/* Sidebar */}
{/* Enhanced glassmorphism with light/dark support */}
<aside className="w-72 bg-white/60 dark:bg-zinc-900/60 backdrop-blur-2xl border-r border-zinc-200 dark:border-white/5 flex flex-col shrink-0 z-30 shadow-[4px_0_24px_rgba(0,0,0,0.05)] dark:shadow-[4px_0_24px_rgba(0,0,0,0.4)] relative">
{/* Noise texture */}
<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="p-6 relative z-10">
<div className="flex items-center space-x-3 text-zinc-900 dark:text-white mb-10 pl-2">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-zinc-800 to-black dark:from-white dark:to-zinc-400 flex items-center justify-center shadow-lg shadow-black/10 dark:shadow-white/10 ring-1 ring-black/5 dark:ring-white/20">
<Home size={20} className="text-white dark:text-black" />
</div>
<div>
<span className="text-lg font-bold tracking-tight block leading-none">Plano</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400 font-medium tracking-widest uppercase">Realty</span>
</div>
</div>
<nav className="space-y-1">
<div className="text-[10px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-4 pl-4 pt-2">Menu</div>
{user?.role === 'FIELD_AGENT' || user?.role === 'ADMIN' ? (
<>
<SidebarItem to="/emp/fa/dashboard" icon={LayoutDashboard} label="Dashboard" />
<SidebarItem to="/emp/fa/maps" icon={Map} label="Territory Map" />
</>
) : null}
{user?.role === 'ADMIN' && (
<SidebarItem to="/admin/schedule" icon={Calendar} label="Team Schedule" />
)}
{/* Common Links */}
<div className="pt-6 mt-6 border-t border-zinc-200 dark:border-white/5">
<div className="text-[10px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-4 pl-4">System</div>
<SidebarItem to="/" icon={MessageSquare} label="Public Site" />
</div>
</nav>
</div>
<div className="mt-auto p-4 border-t border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20 relative z-10">
<div className="flex items-center space-x-3 p-3 mb-3 rounded-xl bg-white/40 dark:bg-white/5 border border-zinc-200 dark:border-white/5 backdrop-blur-md transition-colors hover:bg-white/60 dark:hover:bg-white/10 group cursor-default">
<div className="w-10 h-10 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-white/5 group-hover:border-zinc-400 dark:group-hover:border-white/20 transition-colors">
<User size={18} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-zinc-900 dark:text-white truncate">{user?.name}</p>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate capitalize tracking-wide font-medium">{user?.role?.replace('_', ' ').toLowerCase()}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center justify-center space-x-2 w-full px-4 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all border border-transparent hover:border-red-500/20"
>
<LogOut size={14} />
<span>Sign Out</span>
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto relative bg-zinc-50 dark:bg-[#09090b] scroll-smooth transition-colors duration-300">
<Outlet />
</main>
<Chatbot />
</div>
);
};
export default Layout;
+84
View File
@@ -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 (
<div
ref={divRef}
onMouseMove={handleMouseMove}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// Light: bg-white, Dark: bg-zinc-800
className={`group relative rounded-3xl bg-white dark:bg-zinc-800 overflow-hidden shadow-sm dark:shadow-none ${className}`}
>
{/*
Rainbow Glow Layer
- Background: Full conic RGB rainbow (Works on both, provides the color)
- Mask reveals transparency
*/}
<div
className='pointer-events-none absolute inset-0 opacity-0 transition duration-300'
style={{
opacity,
background: `conic-gradient(from 0deg, #ff0000, #ff8800, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)`,
WebkitMaskImage: `radial-gradient(500px circle at ${position.x}px ${position.y}px, black, transparent 40%)`,
maskImage: `radial-gradient(500px circle at ${position.x}px ${position.y}px, black, transparent 40%)`,
}}
/>
{/*
Inner Mask (Content Container)
- Light Mode: bg-white/90 (Opaque white with slight blur)
- Dark Mode: bg-zinc-950/90
*/}
<div className="absolute inset-[2px] rounded-[22px] bg-white/90 dark:bg-zinc-950/90 backdrop-blur-xl z-10 transition-colors duration-300" />
{/* Surface Shine (Subtle top sheen) */}
<div
className='pointer-events-none absolute inset-0 opacity-0 transition duration-300 z-20 mix-blend-overlay'
style={{
opacity,
background: `radial-gradient(400px circle at ${position.x}px ${position.y}px, rgba(255,255,255,0.3), transparent 40%)`,
}}
/>
{/* Light Mode Border (to separate card when not glowing) */}
<div className="absolute inset-0 rounded-3xl border border-zinc-200 dark:border-transparent pointer-events-none z-20" />
{/* Content */}
<div className="relative h-full z-30 flex flex-col">
{children}
</div>
</div>
);
};
+81
View File
@@ -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) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
),
Shield: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
</svg>
),
Chart: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M6 16.5v2.25A2.25 2.25 0 008.25 21h7.5A2.25 2.25 0 0018 18.75V16.5m-12 0h12" />
</svg>
),
MapPin: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6" {...props}>
<path fillRule="evenodd" d="M11.54 22.351l.07.04.028.016a.76.76 0 00.723 0l.028-.015.071-.041a16.975 16.975 0 001.144-.742 19.58 19.58 0 002.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 00-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 002.682 2.282 16.975 16.975 0 001.145.742zM12 13.5a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
),
Check: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5 text-cyan" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
),
Star: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-yellow-400" {...props}>
<path fillRule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clipRule="evenodd" />
</svg>
),
Phone: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
),
Warning: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-full h-full" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
),
Clock: (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-full h-full" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
};
+23
View File
@@ -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
};
+59
View File
@@ -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 (
<AuthContext.Provider value={{ user, isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
+24
View File
@@ -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 (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
+385
View File
@@ -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 (
<MockStoreContext.Provider value={{
properties,
setProperties,
users,
meetings,
updatePropertyStatus,
addMeeting
}}>
{children}
</MockStoreContext.Provider>
);
};
export const useMockStore = () => useContext(MockStoreContext);
+53
View File
@@ -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;
}
+22
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<MockStoreProvider>
<AuthProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</AuthProvider>
</MockStoreProvider>
</BrowserRouter>
</React.StrictMode>,
)
+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;
+43
View File
@@ -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
},
};
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})