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;