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