feat: Release CRM V2 (Clean History)

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