feat: complete dashboard widgets, fix tooltips, integrate chatbot, and update docs
This commit is contained in:
@@ -1,134 +1,113 @@
|
|||||||
# Plano Realty CRM - Field Service & Sales Platform
|
# Plano Realty CRM - Geospatial Field Service 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.
|
> **The Next Generation of Roofing Sales & Territory Management**
|
||||||
|
|
||||||
## 🚀 Product Overview
|
**Plano Realty CRM** is an advanced, data-driven platform designed to revolutionize how roofing professionals in Plano, Texas manage their territories, leads, and customer interactions. By combining geospatial intelligence, AI-driven insights, and intuitive workflows, we bridge the gap between field agents and office operations.
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 🗺️ Map Functionality & Geospatial Logic
|
|
||||||
|
|
||||||
The map interface (`src/pages/Maps.jsx`) is the core operational view, overlaying business data on real-world geography.
|
|
||||||
|
|
||||||
### 1. Visualization & Overlays
|
|
||||||
- **Rendering Engine**: Powered by `react-leaflet`.
|
|
||||||
- **Polygons**: Property boundaries are drawn using the `Polygon` component, taking an array of `[lat, lng]` coordinates.
|
|
||||||
- **Status Styling**:
|
|
||||||
- **Hot Lead**: Red Fill (`#ef4444`)
|
|
||||||
- **Customer**: Emerald Fill (`#10b981`)
|
|
||||||
- **Renovated**: Blue Fill (`#3b82f6`)
|
|
||||||
- **Neutral**: Zinc Fill (Default)
|
|
||||||
|
|
||||||
### 2. Interaction & Data Retrieval
|
|
||||||
- **Click-to-Reveal**: Clicking a polygon triggers `onSelect(property)`, opening the **Property Drawer**.
|
|
||||||
- **Returns**: Full `propertyData` (Address, Build Year, Value) and `ownerData` (Name, Contact, Intent).
|
|
||||||
- **Reverse Geocoding (New Property)**:
|
|
||||||
- **Trigger**: Clicking an empty map area (Field Agents only).
|
|
||||||
- **API Call**: `https://nominatim.openstreetmap.org/reverse`
|
|
||||||
- **Parameters**: `lat`, `lon`, `format='json'`.
|
|
||||||
- **Return**: Returns a JSON object where `display_name` is extracted as the property address.
|
|
||||||
|
|
||||||
## 📚 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 Satyam-Rastogi for Plano Realty CRM**
|
|
||||||
|
## 🏛️ PART 1: PRODUCT OVERVIEW
|
||||||
|
|
||||||
|
### 🌟 Value Proposition
|
||||||
|
In a competitive roofing market, speed and data are everything. Traditional spreadsheets and paper maps leave money on the table. **Plano Realty CRM** solves this by providing:
|
||||||
|
- **Precision Targeting**: Identify "Golden Leads" (older homes with high value) instantly on a map.
|
||||||
|
- **Operational Efficiency**: Automate scheduling and follow-ups with our AI Concierge.
|
||||||
|
- **Real-Time Intelligence**: Know the storm risk and roof condition of every property before you knock.
|
||||||
|
|
||||||
|
### 🤖 The "Plano AI Assistant" (Chatbot)
|
||||||
|
Our AI is not just a support bot; it is a context-aware worker that understands your business data.
|
||||||
|
|
||||||
|
#### For Field Agents (Workers)
|
||||||
|
- **"Smart Querying"**: Ask *“Show me the properties built before 2000”* or *“Which homeowner wants to sell?”*.
|
||||||
|
- **Lead Qualification**: The AI knows which leads are "Hot" and prompts you to prioritize them.
|
||||||
|
- **Risk Analysis**: Ask *“What is the current storm risk?”* to get a breakdown of wind speeds vs. roof vulnerability.
|
||||||
|
|
||||||
|
#### For Customers (Homeowners)
|
||||||
|
- **Instant Diagnostics**: (Future) Schedule inspections without waiting on hold.
|
||||||
|
- **Project Updates**: Check the status of their roof repair.
|
||||||
|
|
||||||
|
#### For Guests (Website Visitors)
|
||||||
|
- **Lead Capture**: The AI engages visitors, answers generic questions about roofing services, and encourages them to sign up or request a free estimate.
|
||||||
|
|
||||||
|
### 📊 The Data Dashboard
|
||||||
|
A 360-degree view of your territory's health.
|
||||||
|
|
||||||
|
1. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year).
|
||||||
|
2. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs.
|
||||||
|
3. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs.
|
||||||
|
4. **Roof Health**: A visual breakdown of the entire territory's roof conditions (Good, Fair, Needs Repair).
|
||||||
|
5. **Owner Intent Funnel**: Tracks the pipeline from "Willing to Sell" -> "Hot Leads".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 USER ROLES & DEMO ACCOUNTS
|
||||||
|
|
||||||
|
To explore the platform, use these pre-configured accounts.
|
||||||
|
|
||||||
|
### 1. Field Agent (Worker)
|
||||||
|
*The primary user. Can view maps, access detailed property data, and manage leads.*
|
||||||
|
- **Username/ID**: `EMP001`
|
||||||
|
- **Password**: `password123`
|
||||||
|
- **Role Capabilities**:
|
||||||
|
- Access Dashboard & Widget Data.
|
||||||
|
- View/Edit Lead Status (Hot, Cold, Customer).
|
||||||
|
- Use Chatbot with full data context.
|
||||||
|
|
||||||
|
### 2. Administrator
|
||||||
|
*The manager. Sees all agents and global stats.*
|
||||||
|
- **Username/ID**: `ADMIN01`
|
||||||
|
- **Password**: `adminpass`
|
||||||
|
- **Role Capabilities**:
|
||||||
|
- All Agent capabilities + User Management (Mock).
|
||||||
|
|
||||||
|
### 3. Customer (Homeowner)
|
||||||
|
*The client. Limited view of their own property.*
|
||||||
|
- **Username**: `john_doe`
|
||||||
|
- **Password**: `customer123`
|
||||||
|
- **Role Capabilities**:
|
||||||
|
- View personal property details.
|
||||||
|
- Request Service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 PART 2: DEVELOPER GUIDE
|
||||||
|
|
||||||
|
### 🛠 Tech Stack & Architecture
|
||||||
|
|
||||||
|
- **Frontend**: React 18 (Vite), Tailwind CSS, Glassmorphism UI.
|
||||||
|
- **State**: `MockStore` (Simulates Backend/DB), `AuthContext` (Session Management).
|
||||||
|
- **AI Engine**: Groq SDK (`qwen-2.5-32b`) injected with dynamic system prompts.
|
||||||
|
- **Visuals**: `react-leaflet` (Maps), `recharts` (Analytics), `lucide-react` (Icons).
|
||||||
|
|
||||||
|
### 🧠 Deep Dive: How It Works
|
||||||
|
|
||||||
|
#### 1. The "Mock Store" (Data Layer)
|
||||||
|
Located in `src/data/mockStore.jsx`. It generates ~60 realistic property records using weighted randomization algorithms to create organic-looking data distributions (e.g., 10% bad roofs, random build years).
|
||||||
|
- **Why?**: To provide a rich, testable dataset without needing a live backend database.
|
||||||
|
|
||||||
|
#### 2. AI Context Injection
|
||||||
|
Located in `src/components/Chatbot.jsx`.
|
||||||
|
- When a user sends a message, we don't just send their text. We inject a **System Prompt** containing live dashboard metrics.
|
||||||
|
- **Example Injection**:
|
||||||
|
```text
|
||||||
|
SYSTEM: You are a roofing assistant.
|
||||||
|
CONTEXT:
|
||||||
|
- 45 Total Properties
|
||||||
|
- 12 Golden Leads (Top: 123 Maple Dr)
|
||||||
|
- 8 Hot Leads needing canvassing
|
||||||
|
```
|
||||||
|
- This allows the AI to answer specific questions accurately.
|
||||||
|
|
||||||
|
#### 3. Leaflet Customization
|
||||||
|
Located in `src/pages/Maps.jsx`.
|
||||||
|
- We use custom SVGs overlays on the map.
|
||||||
|
- **Reverse Geocoding**: Clicking a blank spot calls the OpenStreetMap API to fetch the address, allowing agents to "create" new property records on the fly.
|
||||||
|
|
||||||
|
### 🚀 Setup & Contribution
|
||||||
|
|
||||||
|
1. **Clone**: `git clone <repo>`
|
||||||
|
2. **Install**: `pnpm install`
|
||||||
|
3. **Env**: Create `.env` with `VITE_GROQ_API_KEY` and `VITE_OPENWEATHER_API_KEY`.
|
||||||
|
4. **Run**: `pnpm run dev`
|
||||||
|
|
||||||
|
**Maintained by Satyam Rastogi**
|
||||||
|
|||||||
Generated
+400
@@ -21,6 +21,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1"
|
||||||
@@ -1075,6 +1076,42 @@
|
|||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.53",
|
"version": "1.0.0-beta.53",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||||
@@ -1432,6 +1469,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1477,6 +1526,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -1577,6 +1689,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
@@ -2153,6 +2271,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2170,6 +2409,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
@@ -2298,6 +2543,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -2566,6 +2821,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -3036,6 +3297,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -3069,6 +3340,15 @@
|
|||||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -4536,6 +4816,13 @@
|
|||||||
"react": "^19.2.3"
|
"react": "^19.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
@@ -4577,6 +4864,30 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -4658,6 +4969,52 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-parse": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -4691,6 +5048,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -5033,6 +5396,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -5239,6 +5608,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -5273,6 +5651,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ GUIDELINES:
|
|||||||
|
|
||||||
const Chatbot = () => {
|
const Chatbot = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { addMeeting, meetings } = useMockStore();
|
const { addMeeting, meetings, properties } = useMockStore();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -127,6 +127,44 @@ ${scheduleSummary}`;
|
|||||||
systemContext += `\n\nCURRENT USER CONTEXT: Guest (Not Logged In)`;
|
systemContext += `\n\nCURRENT USER CONTEXT: Guest (Not Logged In)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- INJECT DASHBOARD DATA ---
|
||||||
|
// Calculate real-time metrics to give the AI context about the territory
|
||||||
|
if (properties && properties.length > 0) {
|
||||||
|
const totalProps = properties.length;
|
||||||
|
|
||||||
|
// 1. Golden Leads
|
||||||
|
const goldenLeads = properties.filter(p =>
|
||||||
|
p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000
|
||||||
|
);
|
||||||
|
const goldenLeadAddresses = goldenLeads.slice(0, 3).map(p => p.propertyData.propertyAddress).join(', ');
|
||||||
|
|
||||||
|
// 2. Hot Leads
|
||||||
|
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
|
||||||
|
|
||||||
|
// 3. Roof Health
|
||||||
|
const roofCounts = properties.reduce((acc, p) => {
|
||||||
|
const c = p.propertyData.roofCondition || 'Unknown';
|
||||||
|
acc[c] = (acc[c] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// 4. Intent
|
||||||
|
const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length;
|
||||||
|
const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length;
|
||||||
|
|
||||||
|
// 5. Risk Index (simplified calculation for context)
|
||||||
|
const vulnerableCount = (roofCounts['Fair'] || 0) + (roofCounts['Needs Repair'] || 0);
|
||||||
|
const riskPercent = Math.round((vulnerableCount / totalProps) * 100);
|
||||||
|
|
||||||
|
systemContext += `\n\nLIVE DASHBOARD INTELLIGENCE (Use this to answer questions about leads and territory status):
|
||||||
|
- Total Properties in Territory: ${totalProps}
|
||||||
|
- GOLDEN LEADS (High Value + Older Homes): ${goldenLeads.length} found. (Top examples: ${goldenLeadAddresses}...)
|
||||||
|
- HOT LEADS (Priority for Canvassing): ${hotLeads.length} properties.
|
||||||
|
- OWNER INTENT: ${willingToSell} owners willing to sell, ${willingToRent} willing to rent.
|
||||||
|
- ROOF HEALTH BREAKDOWN: Excellent: ${roofCounts['Excellent'] || 0}, Good: ${roofCounts['Good'] || 0}, Fair: ${roofCounts['Fair'] || 0}, Needs Repair: ${roofCounts['Needs Repair'] || 0}.
|
||||||
|
- RISK ANALYSIS: ${riskPercent}% of territory has vulnerable roofs.`;
|
||||||
|
}
|
||||||
|
|
||||||
const chatCompletion = await groq.chat.completions.create({
|
const chatCompletion = await groq.chat.completions.create({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemContext },
|
{ role: 'system', content: systemContext },
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
export const InfoTooltip = ({ text }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setCoords({
|
||||||
|
top: rect.top - 10, // Default to slightly above
|
||||||
|
left: rect.left + rect.width / 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
updatePosition();
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
window.addEventListener('scroll', updatePosition);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updatePosition);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
};
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className="cursor-help inline-flex items-center justify-center ml-2"
|
||||||
|
>
|
||||||
|
<Info size={14} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isVisible && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] w-48 p-2 bg-zinc-900 text-xs text-zinc-100 rounded-lg shadow-xl border border-zinc-700 text-center pointer-events-none transition-opacity duration-200"
|
||||||
|
style={{
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
transform: 'translate(-50%, -100%)', // Centered and above
|
||||||
|
opacity: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
{/* Tiny triangle pointer */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-900"></div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer, ZAxis } from 'recharts';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
import { InfoTooltip } from '../InfoTooltip';
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md z-50">
|
||||||
|
<p className="text-zinc-100 font-bold mb-1">{data.address}</p>
|
||||||
|
<p className="text-zinc-400 text-xs">Built: {data.year}</p>
|
||||||
|
<p className="text-emerald-400 text-xs">Value: ${data.value.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GoldenLeadsScatter = ({ properties }) => {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
return properties.map(p => ({
|
||||||
|
x: p.propertyData.yearBuilt,
|
||||||
|
y: p.propertyData.currentEstimatedMarketValue,
|
||||||
|
z: 100, // bubble size
|
||||||
|
address: p.propertyData.propertyAddress.split(',')[0],
|
||||||
|
year: p.propertyData.yearBuilt,
|
||||||
|
value: p.propertyData.currentEstimatedMarketValue,
|
||||||
|
isGolden: p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000
|
||||||
|
}));
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightCard className="h-full flex flex-col">
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="mb-4 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
|
||||||
|
Golden Leads
|
||||||
|
<InfoTooltip text="High-value properties built before 2000. These are prime candidates for renovation and modernization." />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">High Value + Older Homes</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-[10px] px-2 py-1 rounded font-bold uppercase tracking-wider">
|
||||||
|
Opportunity Zone
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full min-h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ScatterChart
|
||||||
|
margin={{ top: 20, right: 20, bottom: 20, left: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="x"
|
||||||
|
name="Year Built"
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||||
|
tickCount={5}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="y"
|
||||||
|
name="Value"
|
||||||
|
unit="$"
|
||||||
|
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||||
|
tickFormatter={(value) => `${value / 1000}k`}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: '3 3' }} />
|
||||||
|
<Scatter name="Properties" data={data} fill="#eab308" shape="circle" />
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
import { InfoTooltip } from '../InfoTooltip';
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
|
||||||
|
<p className="text-zinc-100 font-bold mb-1">{payload[0].payload.name}</p>
|
||||||
|
<p className="text-zinc-400 text-xs">
|
||||||
|
{payload[0].value} Owners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OwnerIntentFunnel = ({ properties }) => {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const total = properties.length;
|
||||||
|
const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length;
|
||||||
|
const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length;
|
||||||
|
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead').length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'Total Owners', value: total, color: '#3f3f46' }, // zinc-700
|
||||||
|
{ name: 'Willing to Sell', value: willingToSell, color: '#8b5cf6' }, // violet-500
|
||||||
|
{ name: 'Willing to Rent', value: willingToRent, color: '#3b82f6' }, // blue-500
|
||||||
|
{ name: 'Hot Leads', value: hotLeads, color: '#f97316' }, // orange-500
|
||||||
|
];
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightCard className="h-full flex flex-col">
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
|
||||||
|
Owner Intent
|
||||||
|
<InfoTooltip text='Classification of owners based on willingness to sell or rent. Prioritize "Hot Leads" for immediate canvassing.' />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Conversion pipeline</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full min-h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
layout="vertical"
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 0, right: 30, left: 20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={100}
|
||||||
|
tick={{ fill: '#71717a', fontSize: 11, fontWeight: 600 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
|
||||||
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={24}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
import { InfoTooltip } from '../InfoTooltip';
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
|
||||||
|
<p className="text-zinc-100 font-bold mb-1">Rating: {label}/10</p>
|
||||||
|
<p className="text-zinc-400 text-xs">
|
||||||
|
Potential Value: <span className="text-emerald-400 font-bold">${payload[0].value.toLocaleString()}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RevenueByNeighborhood = ({ properties }) => {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
// Group by neighborhood rating for now (simulating "Neighborhoods")
|
||||||
|
const groups = properties.reduce((acc, p) => {
|
||||||
|
const rating = p.propertyData.neighborhoodRating || 0;
|
||||||
|
// Summing Potential Revenue (using repair cost as proxy or just market value significance)
|
||||||
|
// Let's use Renovation Cost to show "Repair Opportunity"
|
||||||
|
const value = p.propertyData.renovationCost || 0;
|
||||||
|
acc[rating] = (acc[rating] || 0) + value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.keys(groups).map(key => ({
|
||||||
|
rating: key,
|
||||||
|
value: groups[key]
|
||||||
|
})).sort((a, b) => b.rating - a.rating); // Higher rated neighborhoods first
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightCard className="h-full flex flex-col">
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
|
||||||
|
Revenue Potential
|
||||||
|
<InfoTooltip text="Estimated revenue opportunity based on neighborhood rating and average repair costs." />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">By Neighborhood Rating</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full min-h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#3f3f46" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="rating"
|
||||||
|
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => `$${value / 1000}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
<Bar dataKey="value" fill="#10b981" radius={[4, 4, 0, 0]} barSize={30} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
import { InfoTooltip } from '../InfoTooltip';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
'Excellent': '#10b981', // emerald-500
|
||||||
|
'Good': '#3b82f6', // blue-500
|
||||||
|
'Fair': '#eab308', // yellow-500
|
||||||
|
'Needs Repair': '#ef4444' // red-500
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
|
||||||
|
<p className="text-zinc-100 font-bold mb-1">{payload[0].name}</p>
|
||||||
|
<p className="text-zinc-400 text-xs">
|
||||||
|
{payload[0].value} Properties ({((payload[0].payload.percent || 0) * 100).toFixed(0)}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoofConditionChart = ({ properties }) => {
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const counts = properties.reduce((acc, p) => {
|
||||||
|
const condition = p.propertyData.roofCondition || 'Unknown';
|
||||||
|
acc[condition] = (acc[condition] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const total = properties.length;
|
||||||
|
|
||||||
|
return Object.keys(counts).map(key => ({
|
||||||
|
name: key,
|
||||||
|
value: counts[key],
|
||||||
|
percent: counts[key] / total
|
||||||
|
}));
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightCard className="h-full flex flex-col">
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
|
||||||
|
Roof Condition
|
||||||
|
<InfoTooltip text="Breakdown of roof health across all properties. Data derived from recent inspections and aerial imagery." />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Territory health snapshot</p>
|
||||||
|
</div>
|
||||||
|
{/* Legend / Key - simpler than Recharts legend */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-zinc-500">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500"></div> Good
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-zinc-500">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500"></div> Bad
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full min-h-[250px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[entry.name] || '#6b7280'} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Center Text */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="block text-3xl font-black text-zinc-900 dark:text-white">{properties.length}</span>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-zinc-500 tracking-wider">Total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
import { Wind, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||||
|
import { InfoTooltip } from '../InfoTooltip';
|
||||||
|
|
||||||
|
export const WeatherRiskGauge = ({ properties, weather }) => {
|
||||||
|
const riskScore = useMemo(() => {
|
||||||
|
// 1. Weather Factor (0-50 points)
|
||||||
|
// Wind > 20mph = 50pts, 0mph = 0pts
|
||||||
|
let weatherScore = 0;
|
||||||
|
if (weather && weather.wind) {
|
||||||
|
weatherScore = Math.min(weather.wind.speed * 2.5, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Territory Vulnerability (0-50 points)
|
||||||
|
// % of roofs in "Needs Repair" or "Fair"
|
||||||
|
const vulnerableCount = properties.filter(p =>
|
||||||
|
['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition)
|
||||||
|
).length;
|
||||||
|
const vulnerabilityRatio = vulnerableCount / (properties.length || 1);
|
||||||
|
const roofScore = vulnerabilityRatio * 50;
|
||||||
|
|
||||||
|
return Math.min(Math.round(weatherScore + roofScore), 100);
|
||||||
|
}, [properties, weather]);
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: riskScore },
|
||||||
|
{ value: 100 - riskScore }
|
||||||
|
];
|
||||||
|
|
||||||
|
const getColor = (score) => {
|
||||||
|
if (score < 30) return '#10b981'; // Green
|
||||||
|
if (score < 70) return '#eab308'; // Yellow
|
||||||
|
if (score >= 70) return '#ef4444'; // Red
|
||||||
|
return '#10b981';
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = getColor(riskScore);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightCard className="h-full flex flex-col">
|
||||||
|
<div className="p-6 flex-1 flex flex-col items-center justify-between text-center relative overflow-hidden">
|
||||||
|
|
||||||
|
{/* Background Glow */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none" style={{ background: `radial-gradient(circle at center, ${color}, transparent)` }} />
|
||||||
|
|
||||||
|
<div className="z-10 w-full mb-2">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center justify-center gap-2">
|
||||||
|
{riskScore > 70 ? <AlertTriangle size={18} className="text-red-500" /> : <ShieldCheck size={18} className="text-emerald-500" />}
|
||||||
|
Risk Index
|
||||||
|
<InfoTooltip text="Assessment of storm risk based on current wind speed and percentage of vulnerable roofs (Fair/Needs Repair)." />
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Storm Impact Probability</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-[140px] flex items-end justify-center">
|
||||||
|
{/* Speedometer Chart */}
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="85%" // Shift down to make it a semi-circle
|
||||||
|
startAngle={180}
|
||||||
|
endAngle={0}
|
||||||
|
innerRadius={80}
|
||||||
|
outerRadius={100}
|
||||||
|
stroke="none"
|
||||||
|
paddingAngle={0}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
<Cell fill={color} />
|
||||||
|
<Cell fill="#27272a" /> {/* zinc-800 for empty part */}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Score in Center */}
|
||||||
|
<div className="absolute bottom-0 text-center pb-2">
|
||||||
|
<div className="text-5xl font-black text-zinc-900 dark:text-white leading-none">
|
||||||
|
{riskScore}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||||
|
/100
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 w-full gap-4 mt-4 z-10">
|
||||||
|
<div className="bg-zinc-100 dark:bg-white/5 rounded-lg p-2 flex flex-col items-center border border-zinc-200 dark:border-white/5">
|
||||||
|
<span className="text-[10px] uppercase text-zinc-500 font-bold">Wind Exp.</span>
|
||||||
|
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||||
|
<Wind size={12} /> {weather?.wind?.speed ? Math.round(weather.wind.speed) : 0} mph
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-100 dark:bg-white/5 rounded-lg p-2 flex flex-col items-center border border-zinc-200 dark:border-white/5">
|
||||||
|
<span className="text-[10px] uppercase text-zinc-500 font-bold">Vulnerable</span>
|
||||||
|
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">
|
||||||
|
{Math.round((properties.filter(p => ['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition)).length / properties.length) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
+52
-12
@@ -165,11 +165,27 @@ function generateEmail(name, company = null) {
|
|||||||
return `${first}.${last}@${domain}`;
|
return `${first}.${last}@${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weighted Random Helper
|
||||||
|
function getWeightedRandom(items, weights) {
|
||||||
|
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||||
|
const random = Math.random() * totalWeight;
|
||||||
|
let weightSum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
weightSum += weights[i];
|
||||||
|
if (random <= weightSum) {
|
||||||
|
return items[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items[items.length - 1]; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
function generateProperties() {
|
function generateProperties() {
|
||||||
return RAW_LOCATIONS.map((loc, index) => {
|
return RAW_LOCATIONS.map((loc, index) => {
|
||||||
const isCommercial = loc.type === "Commercial" || loc.type === "Apartments";
|
const isCommercial = loc.type === "Commercial" || loc.type === "Apartments";
|
||||||
|
|
||||||
const styleIndex = (index * 7) % PROPERTY_STYLES.length;
|
// Shuffle styles slightly differently than index-based to avoid repeating patterns
|
||||||
|
const styleIndex = Math.floor(Math.random() * PROPERTY_STYLES.length);
|
||||||
const style = isCommercial ? null : PROPERTY_STYLES[styleIndex];
|
const style = isCommercial ? null : PROPERTY_STYLES[styleIndex];
|
||||||
|
|
||||||
let ownerName, ownerEmail, occupation, income, employerName;
|
let ownerName, ownerEmail, occupation, income, employerName;
|
||||||
@@ -191,20 +207,44 @@ function generateProperties() {
|
|||||||
income = ["$80k - $120k", "$120k - $180k", "$200k+", "$60k - $90k"][index % 4];
|
income = ["$80k - $120k", "$120k - $180k", "$200k+", "$60k - $90k"][index % 4];
|
||||||
}
|
}
|
||||||
|
|
||||||
const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + (index % 15);
|
// Randomize Year and Value to break lines
|
||||||
const sqft = isCommercial ? 12000 + (index * 500) : style.areaBase + (index * 132);
|
const yearOffset = Math.floor(Math.random() * 20) - 10; // +/- 10 years
|
||||||
const value = isCommercial ? 2500000 + (index * 100000) : style.baseValue + (index * 12500);
|
const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + yearOffset;
|
||||||
|
|
||||||
const isRented = index % 3 === 0;
|
const valueVariance = (Math.random() * 0.4) - 0.2; // +/- 20%
|
||||||
|
const baseSqft = isCommercial ? 12000 + (index * 500) : style.areaBase;
|
||||||
|
const sqft = Math.floor(baseSqft * (1 + (valueVariance / 2))); // Size correlates with value but not perfectly
|
||||||
|
|
||||||
|
const baseVal = isCommercial ? 2500000 + (index * 100000) : style.baseValue;
|
||||||
|
const value = Math.floor(baseVal * (1 + valueVariance));
|
||||||
|
|
||||||
|
const isRented = Math.random() > 0.7; // 30% rented
|
||||||
const tenantName = isRented ? (isCommercial ? "Multiple Commercial Tenants" : REAL_NAMES[(index + 5) % REAL_NAMES.length]) : null;
|
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 tenantOcc = isRented ? (isCommercial ? "Retail/Office Mix" : OCCUPATIONS[(index + 3) % OCCUPATIONS.length]) : null;
|
||||||
const rentAmount = isRented ? Math.floor(value * 0.008 / 100) * 100 : null;
|
const rentAmount = isRented ? Math.floor(value * 0.008 / 100) * 100 : null;
|
||||||
|
|
||||||
|
// Weighted Roof Condition
|
||||||
|
// 40% Good, 30% Fair, 20% Excellent, 10% Needs Repair
|
||||||
|
const roofCondition = getWeightedRandom(
|
||||||
|
["Good", "Fair", "Excellent", "Needs Repair"],
|
||||||
|
[0.4, 0.3, 0.2, 0.1]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Canvassing Status Randomization
|
||||||
|
// 60% Neutral, 20% Hot Lead, 10% Customer, 10% Renovated
|
||||||
|
const status = isCommercial ? "Neutral" : getWeightedRandom(
|
||||||
|
["Neutral", "Hot Lead", "Customer", "Renovated"],
|
||||||
|
[0.6, 0.2, 0.1, 0.1]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neighborhood Rating (Weighted towards high 7-9)
|
||||||
|
const neighRating = getWeightedRandom([7, 8, 9, 10, 6, 5], [0.3, 0.3, 0.2, 0.1, 0.05, 0.05]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
center: [loc.lat, loc.lng],
|
center: [loc.lat, loc.lng],
|
||||||
polygon: generatePolygon(loc.lat, loc.lng),
|
polygon: generatePolygon(loc.lat, loc.lng),
|
||||||
canvassingStatus: isCommercial ? "Neutral" : style.canvassingStatus,
|
canvassingStatus: status,
|
||||||
photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
|
photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
|
||||||
propertyData: {
|
propertyData: {
|
||||||
propertyId: `P-${2600 + index}`,
|
propertyId: `P-${2600 + index}`,
|
||||||
@@ -217,9 +257,9 @@ function generateProperties() {
|
|||||||
numberOfParkingSpaces: isCommercial ? 25 : 2,
|
numberOfParkingSpaces: isCommercial ? 25 : 2,
|
||||||
currentEstimatedMarketValue: value,
|
currentEstimatedMarketValue: value,
|
||||||
propertyTaxAssessmentValue: Math.floor(value * 0.85),
|
propertyTaxAssessmentValue: Math.floor(value * 0.85),
|
||||||
roofCondition: ["Good", "Fair", "Excellent", "Needs Repair"][index % 4],
|
roofCondition: roofCondition,
|
||||||
renovationDescription: isCommercial ? "Commercial Maintenance" : style.desc,
|
renovationDescription: isCommercial ? "Commercial Maintenance" : style.desc,
|
||||||
renovationCost: Math.floor(value * 0.05),
|
renovationCost: Math.floor(value * (Math.random() * 0.05 + 0.01)), // 1-6% of value
|
||||||
lastMajorRenovationDate: "2020-05-15",
|
lastMajorRenovationDate: "2020-05-15",
|
||||||
lastRoofRepairReplacementDate: "2018-02-10",
|
lastRoofRepairReplacementDate: "2018-02-10",
|
||||||
recentMajorRepairs: "Routine Maintenance",
|
recentMajorRepairs: "Routine Maintenance",
|
||||||
@@ -230,12 +270,12 @@ function generateProperties() {
|
|||||||
currentMonthlyRentAmount: rentAmount,
|
currentMonthlyRentAmount: rentAmount,
|
||||||
leaseStartDate: isRented ? "2023-01-01" : null,
|
leaseStartDate: isRented ? "2023-01-01" : null,
|
||||||
leaseEndDate: isRented ? "2025-01-01" : null,
|
leaseEndDate: isRented ? "2025-01-01" : null,
|
||||||
ownerWillingToRent: index % 4 === 0,
|
ownerWillingToRent: !isRented && Math.random() > 0.6,
|
||||||
expectedMonthlyRentAmount: Math.floor(value * 0.008 / 100) * 100,
|
expectedMonthlyRentAmount: Math.floor(value * 0.008 / 100) * 100,
|
||||||
rentalAvailabilityDate: null,
|
rentalAvailabilityDate: null,
|
||||||
rentalTermsPreference: null,
|
rentalTermsPreference: null,
|
||||||
schoolDistrict: "Plano ISD",
|
schoolDistrict: "Plano ISD",
|
||||||
neighborhoodRating: 8 + (index % 3),
|
neighborhoodRating: neighRating,
|
||||||
crimeRateIndex: "Low",
|
crimeRateIndex: "Low",
|
||||||
publicTransportationAccess: "Bus Line 204",
|
publicTransportationAccess: "Bus Line 204",
|
||||||
propertyConditionRating: isCommercial ? 4 : style.condition,
|
propertyConditionRating: isCommercial ? 4 : style.condition,
|
||||||
@@ -260,10 +300,10 @@ function generateProperties() {
|
|||||||
creditScoreRange: "720+",
|
creditScoreRange: "720+",
|
||||||
ownershipType: isCommercial ? "Corporate" : "Individual",
|
ownershipType: isCommercial ? "Corporate" : "Individual",
|
||||||
ownershipPercentage: "100%",
|
ownershipPercentage: "100%",
|
||||||
willingToRentProperty: false,
|
willingToRentProperty: Math.random() > 0.8,
|
||||||
desiredMonthlyRentalPrice: null,
|
desiredMonthlyRentalPrice: null,
|
||||||
rentalConditions: null,
|
rentalConditions: null,
|
||||||
willingToSellProperty: index % 5 === 0,
|
willingToSellProperty: Math.random() > 0.85,
|
||||||
desiredSellingPrice: Math.floor(value * 1.1),
|
desiredSellingPrice: Math.floor(value * 1.1),
|
||||||
minimumAcceptableSellingPrice: Math.floor(value * 1.05)
|
minimumAcceptableSellingPrice: Math.floor(value * 1.05)
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-119
@@ -4,15 +4,83 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
DollarSign, Flame, Calendar, CloudRain, Wind, Droplets, Thermometer,
|
DollarSign, Flame, Calendar, CloudRain, Wind, Droplets, Thermometer,
|
||||||
MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2
|
MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2, ShieldCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import { config } from '../config/env';
|
import { config } from '../config/env';
|
||||||
|
|
||||||
|
// New Widget Imports
|
||||||
|
import { RoofConditionChart } from '../components/dashboard/RoofConditionChart';
|
||||||
|
import { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel';
|
||||||
|
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
|
||||||
|
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
|
||||||
|
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { properties, meetings } = useMockStore();
|
const { properties, meetings } = useMockStore();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// --- Weather State (Lifted) ---
|
||||||
|
const [weather, setWeather] = useState(null);
|
||||||
|
const [forecast, setForecast] = useState([]);
|
||||||
|
const [weatherLoading, setWeatherLoading] = useState(true);
|
||||||
|
const [weatherError, setWeatherError] = useState(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(null);
|
||||||
|
|
||||||
|
const fetchWeather = async () => {
|
||||||
|
const API_KEY = config.weatherApiKey;
|
||||||
|
const CITY = "Plano,US";
|
||||||
|
try {
|
||||||
|
setWeatherLoading(true);
|
||||||
|
|
||||||
|
if (config.isDemoMode(API_KEY)) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
const mockCurrent = {
|
||||||
|
name: "Plano",
|
||||||
|
sys: { country: "US" },
|
||||||
|
main: { temp: 75, temp_max: 82, temp_min: 68, feels_like: 77, humidity: 45 },
|
||||||
|
weather: [{ id: 800, description: "Clear Sky (Demo)" }],
|
||||||
|
wind: { speed: 8 }
|
||||||
|
};
|
||||||
|
const mockForecast = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
dt: Math.floor(Date.now() / 1000) + (i * 10800),
|
||||||
|
main: { temp: 75 + (i % 2 === 0 ? 2 : -2) },
|
||||||
|
weather: [{ id: 800 + (i * 10) }]
|
||||||
|
}));
|
||||||
|
setWeather(mockCurrent);
|
||||||
|
setForecast(mockForecast);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
setWeatherLoading(false);
|
||||||
|
setWeatherError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRes = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`);
|
||||||
|
if (!currentRes.ok) throw new Error(currentRes.statusText);
|
||||||
|
const currentData = await currentRes.json();
|
||||||
|
|
||||||
|
const forecastRes = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`);
|
||||||
|
if (!forecastRes.ok) throw new Error(forecastRes.statusText);
|
||||||
|
const forecastData = await forecastRes.json();
|
||||||
|
|
||||||
|
setWeather(currentData);
|
||||||
|
setForecast(forecastData.list?.slice(0, 5) || []);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
setWeatherLoading(false);
|
||||||
|
setWeatherError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Weather fetch failed", err);
|
||||||
|
setWeatherError(err.message);
|
||||||
|
setWeatherLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWeather();
|
||||||
|
const interval = setInterval(fetchWeather, 600000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Clock for Plano, TX
|
// Clock for Plano, TX
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
@@ -24,11 +92,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
// Derived Metrics & Split Schedule
|
// Derived Metrics & Split Schedule
|
||||||
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
|
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');
|
const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1');
|
||||||
|
|
||||||
// Split into Upcoming vs Past
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -103,7 +167,50 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
{/* --- ANALYTICS ROW 1: Territory Health & Risk --- */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Roof Condition - Donut */}
|
||||||
|
<div className="lg:col-span-1 h-80">
|
||||||
|
<RoofConditionChart properties={properties} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather Risk - Gauge */}
|
||||||
|
<div className="lg:col-span-1 h-80">
|
||||||
|
<WeatherRiskGauge properties={properties} weather={weather} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Weather Widget (Reused) */}
|
||||||
|
<div className="lg:col-span-1 h-80">
|
||||||
|
<RealWeatherWidget
|
||||||
|
weather={weather}
|
||||||
|
forecast={forecast}
|
||||||
|
loading={weatherLoading}
|
||||||
|
error={weatherError}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
onRetry={fetchWeather}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- ANALYTICS ROW 2: Opportunity & Intent --- */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Revenue by Neighborhood */}
|
||||||
|
<div className="lg:col-span-2 h-80">
|
||||||
|
<RevenueByNeighborhood properties={properties} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner Intent Funnel */}
|
||||||
|
<div className="lg:col-span-1 h-80">
|
||||||
|
<OwnerIntentFunnel properties={properties} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- ANALYTICS ROW 3: Golden Leads Scatter --- */}
|
||||||
|
<div className="h-96">
|
||||||
|
<GoldenLeadsScatter properties={properties} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid (Leads & Schedule) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
{/* Left Col: Hot Leads and Schedule */}
|
{/* Left Col: Hot Leads and Schedule */}
|
||||||
@@ -145,11 +252,10 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Split Schedule System */}
|
{/* Right Col: Schedule */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
{/* Upcoming Meetings */}
|
|
||||||
<SpotlightCard>
|
<SpotlightCard>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
@@ -168,7 +274,6 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
|
||||||
{/* Past / Completed Meetings */}
|
|
||||||
<SpotlightCard>
|
<SpotlightCard>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
@@ -187,12 +292,6 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Col: Weather Widget */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
<RealWeatherWidget />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,95 +348,10 @@ const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) =>
|
|||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
const RealWeatherWidget = () => {
|
const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onRetry }) => {
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// --- Icons Logic ---
|
||||||
const getWeatherIcon = (code) => {
|
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 <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 > 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 >= 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)]" />;
|
||||||
@@ -352,11 +366,11 @@ const RealWeatherWidget = () => {
|
|||||||
return <Sun size={16} className="text-amber-500 dark:text-white" />;
|
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 (loading && !weather) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500"><Loader2 className="animate-spin mr-3" /> Syncing OWM...</div></SpotlightCard>;
|
||||||
|
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<SpotlightCard>
|
<SpotlightCard>
|
||||||
<div className="h-80 flex flex-col items-center justify-center text-red-500 dark:text-red-400 p-6 text-center">
|
<div className="h-full flex flex-col items-center justify-center text-red-500 dark:text-red-400 p-6 text-center">
|
||||||
<AlertTriangle size={32} className="mb-4 opacity-80" />
|
<AlertTriangle size={32} className="mb-4 opacity-80" />
|
||||||
<h3 className="font-bold mb-2">Weather Unavailable</h3>
|
<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">
|
<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">
|
||||||
@@ -367,24 +381,23 @@ const RealWeatherWidget = () => {
|
|||||||
Please configure your <code>VITE_OPENWEATHER_API_KEY</code> in the <code>.env</code> file.
|
Please configure your <code>VITE_OPENWEATHER_API_KEY</code> in the <code>.env</code> file.
|
||||||
</p>
|
</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">
|
<button onClick={onRetry} className="mt-4 px-4 py-2 bg-zinc-100 dark:bg-white/10 rounded-lg text-xs font-bold uppercase tracking-wider hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors">
|
||||||
Retry Connection
|
Retry Connection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!weather || !weather.main) return <SpotlightCard><div className="h-80 flex items-center justify-center text-zinc-500">Weather Unavailable</div></SpotlightCard>;
|
if (!weather || !weather.main) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500">Weather Unavailable</div></SpotlightCard>;
|
||||||
|
|
||||||
return (
|
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 h-full">
|
||||||
<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 */}
|
{/* 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="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="relative z-10 p-8 flex flex-col justify-between h-full">
|
||||||
<div className="flex justify-between items-start mb-8">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<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">
|
<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">
|
||||||
@@ -409,15 +422,10 @@ const RealWeatherWidget = () => {
|
|||||||
<div className="mb-2 transform hover:scale-110 transition-transform duration-500 ease-out">
|
<div className="mb-2 transform hover:scale-110 transition-transform duration-500 ease-out">
|
||||||
{getWeatherIcon(weather.weather[0].id)}
|
{getWeatherIcon(weather.weather[0].id)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-10">
|
<div className="grid grid-cols-2 gap-4 my-2">
|
||||||
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
|
<div className="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">
|
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
|
||||||
<Wind size={14} className="mr-2" />
|
<Wind size={14} className="mr-2" />
|
||||||
@@ -435,7 +443,6 @@ const RealWeatherWidget = () => {
|
|||||||
</div>
|
</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">
|
<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) => {
|
{forecast.map((item, index) => {
|
||||||
const date = new Date(item.dt * 1000);
|
const date = new Date(item.dt * 1000);
|
||||||
@@ -459,3 +466,4 @@ const RealWeatherWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user