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;
|
||||
Reference in New Issue
Block a user