Spaces:
Running
Running
| import { useState, useRef, useEffect } from "react"; | |
| // ═══ MOCK DATA ═══ | |
| const mockTransactions = [ | |
| { id: "1", date: "2026-02-23T10:30:00Z", sender: "Jean Dupont", amount: 250.00, branch: "ICC Montréal", status: "deposited" }, | |
| { id: "2", date: "2026-02-22T14:15:00Z", sender: "Marie Tremblay", amount: 150.00, branch: "ICC Québec", status: "deposited" }, | |
| { id: "3", date: "2026-02-22T09:00:00Z", sender: "Pierre Gagnon", amount: 500.00, branch: "ICC Gatineau", status: "pending" }, | |
| { id: "4", date: "2026-02-21T16:45:00Z", sender: "Sophie Lavoie", amount: 75.00, branch: "ICC Sherbrooke", status: "deposited" }, | |
| { id: "5", date: "2026-02-21T11:20:00Z", sender: "Marc Bélanger", amount: 320.00, branch: "ICC Ottawa", status: "deposited" }, | |
| { id: "6", date: "2026-02-20T08:00:00Z", sender: "Anne Roy", amount: 180.00, branch: "ICC Toronto", status: "expired" }, | |
| { id: "7", date: "2026-02-19T13:30:00Z", sender: "Luc Martin", amount: 425.00, branch: "ICC Montréal", status: "deposited" }, | |
| ]; | |
| const mockStats = { | |
| totalTransactions: 847, | |
| totalAmount: 156234.50, | |
| byBranch: [ | |
| { branch: "ICC Montréal", count: 234, total: 45230.00 }, | |
| { branch: "ICC Québec", count: 156, total: 32100.00 }, | |
| { branch: "ICC Gatineau", count: 98, total: 21450.00 }, | |
| { branch: "ICC Ottawa", count: 87, total: 18900.00 }, | |
| { branch: "ICC Toronto", count: 72, total: 15340.00 }, | |
| ], | |
| }; | |
| // ═══ SIMULATED AI RESPONSES ═══ | |
| function simulateResponse(text) { | |
| const lower = text.toLowerCase(); | |
| if (lower.includes("scann") || lower.includes("scan")) { | |
| return { | |
| type: "scan_started", | |
| message: "🔍 Scan lancé pour la période du 25 janvier 2024 au 23 février 2026. Les résultats apparaîtront en temps réel dans le tableau.", | |
| data: { preset: "custom" }, | |
| }; | |
| } | |
| if (lower.includes("transaction") || lower.includes("virement") || lower.includes("montre")) { | |
| return { | |
| type: "transactions", | |
| message: "📊 847 transactions au total. Voici les 7 plus récentes:", | |
| data: { transactions: mockTransactions }, | |
| }; | |
| } | |
| if (lower.includes("stat") || lower.includes("combien") || lower.includes("total")) { | |
| return { | |
| type: "stats", | |
| message: "📈 **Résumé des virements**\n\n💰 **Total:** 847 virements — 156 234,50 $ CAD\n\n🏢 **Top succursales:** Montréal en tête avec 234 virements.", | |
| data: mockStats, | |
| }; | |
| } | |
| if (lower.includes("cherch") || lower.includes("search") || lower.includes("trouve")) { | |
| const filtered = mockTransactions.filter((t) => | |
| t.sender.toLowerCase().includes("dupont") || t.sender.toLowerCase().includes("martin") | |
| ); | |
| return { | |
| type: "search_results", | |
| message: `🔍 ${filtered.length} résultat(s) trouvé(s):`, | |
| data: { transactions: filtered.length > 0 ? filtered : mockTransactions.slice(0, 3) }, | |
| }; | |
| } | |
| return { | |
| type: "message", | |
| message: "Je peux t'aider avec:\n• **Scanner** des courriels Interac\n• **Afficher** les transactions\n• **Statistiques** des virements\n• **Chercher** par nom ou succursale", | |
| }; | |
| } | |
| // ═══ STATUS BADGE ═══ | |
| function StatusBadge({ status }) { | |
| const colors = { | |
| deposited: "bg-emerald-100 text-emerald-700", | |
| pending: "bg-amber-100 text-amber-700", | |
| expired: "bg-slate-100 text-slate-500", | |
| cancelled: "bg-red-100 text-red-600", | |
| }; | |
| return ( | |
| <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${colors[status] || "bg-slate-100 text-slate-500"}`}> | |
| {status || "—"} | |
| </span> | |
| ); | |
| } | |
| // ═══ TRANSACTION TABLE ═══ | |
| function TransactionTable({ transactions }) { | |
| if (!transactions?.length) return null; | |
| return ( | |
| <div className="mt-2 rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm"> | |
| <div className="overflow-x-auto max-h-[220px] overflow-y-auto"> | |
| <table className="w-full text-[11px]"> | |
| <thead className="bg-slate-50 sticky top-0"> | |
| <tr className="border-b border-slate-200"> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">Date</th> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">Expéditeur</th> | |
| <th className="px-3 py-2 text-right font-semibold text-slate-600">Montant</th> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">Succursale</th> | |
| <th className="px-3 py-2 text-center font-semibold text-slate-600">Statut</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {transactions.map((tx, i) => ( | |
| <tr key={tx.id || i} className="border-b border-slate-50 hover:bg-blue-50/30 transition-colors"> | |
| <td className="px-3 py-1.5 text-slate-500 whitespace-nowrap"> | |
| {new Date(tx.date).toLocaleDateString("fr-CA", { day: "2-digit", month: "short" })} | |
| </td> | |
| <td className="px-3 py-1.5 text-slate-800 font-medium">{tx.sender}</td> | |
| <td className="px-3 py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap"> | |
| {Number(tx.amount).toFixed(2)} $ | |
| </td> | |
| <td className="px-3 py-1.5 text-slate-600">{tx.branch}</td> | |
| <td className="px-3 py-1.5 text-center"><StatusBadge status={tx.status} /></td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ═══ STATS CARD ═══ | |
| function StatsCard({ stats }) { | |
| if (!stats) return null; | |
| return ( | |
| <div className="mt-2 rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm p-3 space-y-3"> | |
| <div className="flex gap-3"> | |
| <div className="flex-1 bg-blue-50 rounded-lg p-3 text-center"> | |
| <div className="text-xl font-bold text-blue-700">{stats.totalTransactions}</div> | |
| <div className="text-[10px] text-blue-500 font-semibold uppercase tracking-wide">Virements</div> | |
| </div> | |
| <div className="flex-1 bg-emerald-50 rounded-lg p-3 text-center"> | |
| <div className="text-xl font-bold text-emerald-700"> | |
| {Number(stats.totalAmount).toLocaleString("fr-CA", { minimumFractionDigits: 2 })} $ | |
| </div> | |
| <div className="text-[10px] text-emerald-500 font-semibold uppercase tracking-wide">Total</div> | |
| </div> | |
| </div> | |
| {stats.byBranch?.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Top succursales</div> | |
| <div className="space-y-1.5"> | |
| {stats.byBranch.slice(0, 5).map((b, i) => { | |
| const pct = (b.total / stats.totalAmount) * 100; | |
| return ( | |
| <div key={i}> | |
| <div className="flex items-center justify-between text-[11px] mb-0.5"> | |
| <span className="text-slate-600">{b.branch}</span> | |
| <span className="text-slate-800 font-medium">{b.count} · {Number(b.total).toLocaleString("fr-CA")} $</span> | |
| </div> | |
| <div className="h-1.5 bg-slate-100 rounded-full overflow-hidden"> | |
| <div className="h-full bg-blue-400 rounded-full transition-all" style={{ width: `${pct}%` }} /> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ═══ FORMATTED TEXT ═══ | |
| function FormattedText({ text }) { | |
| return ( | |
| <> | |
| {text.split("\n").map((line, i) => { | |
| const formatted = line.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>'); | |
| return ( | |
| <span key={i}> | |
| {i > 0 && <br />} | |
| <span dangerouslySetInnerHTML={{ __html: formatted }} /> | |
| </span> | |
| ); | |
| })} | |
| </> | |
| ); | |
| } | |
| // ═══ MESSAGE BUBBLE ═══ | |
| function MessageBubble({ message }) { | |
| const isUser = message.role === "user"; | |
| return ( | |
| <div className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}> | |
| {!isUser && ( | |
| <div className="w-7 h-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex-shrink-0 flex items-center justify-center shadow-sm"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| )} | |
| <div className={`max-w-[85%] ${isUser ? "ml-auto" : ""}`}> | |
| <div | |
| className={`rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${ | |
| isUser | |
| ? "bg-blue-500 text-white rounded-tr-md" | |
| : message.type === "error" | |
| ? "bg-red-50 text-red-700 border border-red-200 rounded-tl-md" | |
| : message.type === "scan_started" | |
| ? "bg-blue-50 text-blue-800 border border-blue-200 rounded-tl-md" | |
| : "bg-slate-100 text-slate-700 rounded-tl-md" | |
| }`} | |
| > | |
| <FormattedText text={message.content} /> | |
| </div> | |
| {message.data && (message.type === "transactions" || message.type === "search_results") && ( | |
| <TransactionTable transactions={message.data.transactions} /> | |
| )} | |
| {message.data && message.type === "stats" && <StatsCard stats={message.data} />} | |
| <div className={`text-[10px] text-slate-400 mt-1 ${isUser ? "text-right" : ""}`}> | |
| {message.timestamp.toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ═══ MAIN APP ═══ | |
| export default function ICCChatDemo() { | |
| const [isOpen, setIsOpen] = useState(true); | |
| const [input, setInput] = useState(""); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [messages, setMessages] = useState([ | |
| { | |
| id: "welcome", | |
| role: "assistant", | |
| content: | |
| "Bonjour! 👋 Je suis ton assistant ICC. Tu peux me demander:\n\n• **Scanner** — \"Scanne les courriels du 25 janvier 2024 à aujourd'hui\"\n• **Voir les transactions** — \"Montre-moi les virements\"\n• **Statistiques** — \"Combien de virements au total?\"\n• **Chercher** — \"Cherche les virements de Jean Dupont\"", | |
| type: "message", | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| const endRef = useRef(null); | |
| useEffect(() => { | |
| endRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const send = async () => { | |
| if (!input.trim() || isLoading) return; | |
| const text = input.trim(); | |
| setInput(""); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { id: `u-${Date.now()}`, role: "user", content: text, timestamp: new Date() }, | |
| ]); | |
| setIsLoading(true); | |
| // Simulate AI processing delay | |
| await new Promise((r) => setTimeout(r, 800 + Math.random() * 700)); | |
| const response = simulateResponse(text); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { id: `a-${Date.now()}`, role: "assistant", ...response, timestamp: new Date() }, | |
| ]); | |
| setIsLoading(false); | |
| }; | |
| const quickActions = [ | |
| { label: "Scanner aujourd'hui", cmd: "Scanne les courriels d'aujourd'hui" }, | |
| { label: "Transactions", cmd: "Montre les transactions récentes" }, | |
| { label: "Statistiques", cmd: "Statistiques" }, | |
| { label: "Chercher", cmd: "Cherche Jean Dupont" }, | |
| ]; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-slate-100 via-slate-50 to-blue-50 flex items-center justify-center p-4 font-sans"> | |
| {/* Background pattern */} | |
| <div className="fixed inset-0 opacity-[0.02]" style={{ | |
| backgroundImage: "radial-gradient(circle at 1px 1px, #000 1px, transparent 0)", | |
| backgroundSize: "32px 32px", | |
| }} /> | |
| {/* Demo label */} | |
| <div className="fixed top-6 left-1/2 -translate-x-1/2 z-10 bg-white/80 backdrop-blur-sm border border-slate-200 rounded-full px-5 py-2 shadow-sm"> | |
| <span className="text-sm font-medium text-slate-600"> | |
| 💬 ICC Chat Assistant — <span className="text-blue-600">Démo interactive</span> | |
| </span> | |
| </div> | |
| {/* Chat Panel */} | |
| <div | |
| className={`w-[430px] max-w-full bg-white rounded-2xl shadow-2xl border border-slate-200/80 flex flex-col overflow-hidden transition-all duration-300`} | |
| style={{ height: "min(640px, calc(100vh - 6rem))" }} | |
| > | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-blue-50/50"> | |
| <div className="flex items-center gap-3"> | |
| <div className="relative"> | |
| <div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-md"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| <span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-400 rounded-full border-2 border-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-sm font-bold text-slate-800">Assistant ICC</h3> | |
| <p className="text-[11px] text-slate-400">Gestion intelligente des virements Interac</p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setMessages([messages[0]])} | |
| className="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-100 transition-colors" | |
| title="Effacer" | |
| > | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> | |
| <path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> | |
| <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3" style={{ scrollbarWidth: "thin" }}> | |
| {messages.map((msg) => ( | |
| <MessageBubble key={msg.id} message={msg} /> | |
| ))} | |
| {isLoading && ( | |
| <div className="flex items-start gap-2"> | |
| <div className="w-7 h-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex-shrink-0 flex items-center justify-center"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| <div className="bg-slate-100 rounded-2xl rounded-tl-md px-4 py-3"> | |
| <div className="flex gap-1.5"> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={endRef} /> | |
| </div> | |
| {/* Quick Actions */} | |
| <div className="px-4 py-2 border-t border-slate-100 bg-slate-50/50 flex gap-1.5 overflow-x-auto" style={{ scrollbarWidth: "none" }}> | |
| {quickActions.map((a) => ( | |
| <button | |
| key={a.label} | |
| onClick={() => { setInput(a.cmd); setTimeout(() => { setInput(""); setMessages((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", content: a.cmd, timestamp: new Date() }]); setIsLoading(true); setTimeout(() => { const r = simulateResponse(a.cmd); setMessages((prev) => [...prev, { id: `a-${Date.now()}`, role: "assistant", ...r, timestamp: new Date() }]); setIsLoading(false); }, 800 + Math.random() * 700); }, 50); }} | |
| disabled={isLoading} | |
| className="flex-shrink-0 px-3 py-1.5 text-[11px] font-medium rounded-full border border-slate-200 bg-white text-slate-600 hover:bg-blue-50 hover:border-blue-200 hover:text-blue-700 disabled:opacity-40 transition-colors whitespace-nowrap" | |
| > | |
| {a.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Input */} | |
| <div className="px-4 py-3 border-t border-slate-100 bg-white"> | |
| <div className="flex items-center gap-2 bg-slate-50 rounded-xl border border-slate-200 px-3 py-1.5 focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100 transition-all"> | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} | |
| placeholder="Écris une commande..." | |
| disabled={isLoading} | |
| className="flex-1 bg-transparent text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none py-1.5 disabled:opacity-50" | |
| /> | |
| <button | |
| onClick={send} | |
| disabled={!input.trim() || isLoading} | |
| className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 disabled:bg-slate-200 disabled:text-slate-400 transition-colors" | |
| > | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="m22 2-7 20-4-9-9-4z" /><path d="M22 2 11 13" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |