feat: Release CRM V2 (Clean History)
Squashed commits for V2 release including Dark Mode, Chatbot, and Landing Page enhancements.
@@ -0,0 +1,5 @@
|
|||||||
|
# AI Integration (Groq)
|
||||||
|
VITE_GROQ_API_KEY=Your-API-Key
|
||||||
|
|
||||||
|
# Weather Widget (OpenWeatherMap)
|
||||||
|
VITE_OPENWEATHER_API_KEY=Your-API-Key
|
||||||
@@ -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
|
||||||
@@ -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**
|
||||||
@@ -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] (1–5)
|
||||||
|
- 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)
|
||||||
@@ -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_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
After Width: | Height: | Size: 849 KiB |
|
After Width: | Height: | Size: 995 KiB |
|
After Width: | Height: | Size: 919 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 799 KiB |
|
After Width: | Height: | Size: 926 KiB |
|
After Width: | Height: | Size: 776 KiB |
@@ -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 |
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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='© <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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||