Files
lynkeduppro-crm/src/components/dashboard/ai-assistant.tsx
T
Goutam 0b76c97445 Redesign Team, Support, AI & Rules; solid-orange brand system
- Team Management: crew hero, member gallery + list, role rings, invites
- Support Center: command-center Overview (live status, pulse ribbon, signal)
- AI Assistant: animated orb rings + textured stage
- Rules: rulebook cards (watermark, uniform orange), 3-up responsive grid
- Brand: --grad-brand now solid rgba(253,169,19,.92), white text/icons on
  orange, no orange gradients; dark surfaces (--card-grad/--panel-2) #060608
- Segmented tabs get an on-brand orange active state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:40:29 +05:30

202 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================
// AI Assistant — "Lynk AI", a creative conversational copilot.
// · Empty state : warm greeting, capability cards, prompt chips
// · Active chat : message thread with a typing indicator and a
// simulated, keyword-aware assistant reply
// · Composer : auto-suggesting prompt row + send / new chat
// All responses are canned + client-side so it demos with no API.
// ============================================================
import { useEffect, useRef, useState } from "react";
import { user } from "./account-data";
import { Avatar, Btn, Icon, Pill, useToast } from "./ui";
type Msg = { id: number; from: "me" | "ai"; text: string };
const CAPABILITIES = [
{ icon: "leads", tone: "var(--orange)", title: "Qualify leads", desc: "Score and summarise new leads, draft the first outreach." },
{ icon: "estimates", tone: "var(--blue)", title: "Build estimates", desc: "Turn scope notes into a clean, line-itemed proposal." },
{ icon: "schedule", tone: "var(--green)", title: "Plan the week", desc: "Suggest a crew schedule around weather and priorities." },
{ icon: "leaderboard", tone: "var(--purple)", title: "Analyse pipeline", desc: "Spot stalled deals and where revenue is leaking." },
];
const PROMPTS = [
{ icon: "leads", text: "Summarise my new leads from this week" },
{ icon: "estimates", text: "Draft an estimate for a 28-square asphalt reroof" },
{ icon: "storm", text: "Which territories were hit by the last storm?" },
{ icon: "pipeline", text: "Show deals stuck in the pipeline over 14 days" },
];
let msgSeq = 1;
export function AiAssistant() {
const toast = useToast();
const [messages, setMessages] = useState<Msg[]>([]);
const [draft, setDraft] = useState("");
const [typing, setTyping] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const started = messages.length > 0;
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, typing]);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
function send(text: string) {
const body = text.trim();
if (!body || typing) return;
setMessages((m) => [...m, { id: msgSeq++, from: "me", text: body }]);
setDraft("");
setTyping(true);
timer.current = setTimeout(() => {
setMessages((m) => [...m, { id: msgSeq++, from: "ai", text: replyFor(body) }]);
setTyping(false);
}, 1100);
}
function reset() {
if (timer.current) clearTimeout(timer.current);
setMessages([]); setDraft(""); setTyping(false);
}
return (
<div className="view ai-view">
<div className="ai-head">
<div className="ai-head-l">
<span className="ai-mark"><Icon name="ai" size={20} /></span>
<div>
<div className="ai-eyebrow">LynkedUp Pro</div>
<h1>Lynk AI <Pill tone="orange">Beta</Pill></h1>
</div>
</div>
<div className="ai-head-actions">
<span className="ai-model"><span className="ai-model-dot" /> Opus 4.8</span>
{started && <Btn variant="outline" size="sm" icon="plus" onClick={reset}>New chat</Btn>}
</div>
</div>
<div className="ai-stage card card-pad-0">
<div className="ai-scroll" ref={scrollRef}>
{!started ? (
<div className="ai-hero">
<div className="ai-orb-wrap">
<span className="ai-orb-ring" aria-hidden />
<span className="ai-orb-ring d2" aria-hidden />
<span className="ai-orb-ring d3" aria-hidden />
<span className="ai-hero-orb"><Icon name="ai" size={34} /></span>
</div>
<h2>{greeting()}, {user.firstName}.</h2>
<p>I&apos;m your roofing copilot. Ask me to summarise leads, draft estimates, plan crews or dig into your pipeline.</p>
<div className="ai-caps">
{CAPABILITIES.map((c) => (
<button className="ai-cap" key={c.title} style={{ ["--rc" as string]: c.tone }} onClick={() => send(`Help me ${c.title.toLowerCase()}`)}>
<span className="ai-cap-ic"><Icon name={c.icon} size={18} /></span>
<span className="ai-cap-title">{c.title}</span>
<span className="ai-cap-desc">{c.desc}</span>
</button>
))}
</div>
<div className="ai-suggest">
<span className="ai-suggest-lbl">Try asking</span>
<div className="ai-chips">
{PROMPTS.map((p) => (
<button className="ai-chip" key={p.text} onClick={() => send(p.text)}>
<Icon name={p.icon} size={14} /> {p.text}
</button>
))}
</div>
</div>
</div>
) : (
<div className="ai-thread">
{messages.map((m) => (
<div className={`ai-msg ${m.from}`} key={m.id}>
{m.from === "ai"
? <span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
: <Avatar initials={user.initials} gradient={user.avatarGradient} size={32} />}
<div className="ai-bubble">
{m.text.split("\n").map((line, i) => <p key={i}>{line}</p>)}
{m.from === "ai" && (
<div className="ai-msg-actions">
<button onClick={() => { navigator.clipboard?.writeText(m.text); toast.push({ tone: "success", title: "Copied to clipboard" }); }} aria-label="Copy"><Icon name="copy" size={13} /></button>
<button onClick={() => toast.push({ tone: "success", title: "Thanks for the feedback" })} aria-label="Good response"><Icon name="check" size={13} /></button>
</div>
)}
</div>
</div>
))}
{typing && (
<div className="ai-msg ai">
<span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
<div className="ai-bubble ai-typing"><span /><span /><span /></div>
</div>
)}
</div>
)}
</div>
<div className="ai-composer">
{started && (
<div className="ai-quickrow">
{PROMPTS.slice(0, 3).map((p) => (
<button key={p.text} className="ai-quick" onClick={() => send(p.text)}><Icon name={p.icon} size={13} /> {p.text}</button>
))}
</div>
)}
<div className="ai-inputbar">
<button className="ai-attach" aria-label="Attach" onClick={() => toast.push({ tone: "info", title: "Attachments", desc: "File uploads land here soon." })}><Icon name="paperclip" size={18} /></button>
<textarea
className="ai-textarea"
rows={1}
placeholder="Ask Lynk AI anything about your roofing business…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(draft); } }}
/>
<button className="ai-send" aria-label="Send" disabled={!draft.trim() || typing} onClick={() => send(draft)}><Icon name="send" size={17} /></button>
</div>
<div className="ai-foot">Lynk AI can make mistakes. Verify estimates and figures before sending to owners.</div>
</div>
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* helpers */
/* ---------------------------------------------------------- */
function greeting(): string {
// Deterministic across renders to avoid hydration drift — anchored to a
// fixed demo time-of-day rather than the live clock.
return "Good evening";
}
// Lightweight keyword router so the demo feels responsive without an API.
function replyFor(q: string): string {
const t = q.toLowerCase();
if (t.includes("lead")) {
return "You have 6 new leads this week — 4 inbound from the storm landing page and 2 referrals.\nTop priority: Mark Reynolds (Sector 18) — full roof replacement, insurance claim already filed. I've drafted a first-touch SMS; want me to queue it?";
}
if (t.includes("estimate") || t.includes("proposal") || t.includes("reroep") || t.includes("reroof")) {
return "Here's a starting estimate for a 28-square asphalt reroof:\n• Tear-off & disposal — $3,920\n• Architectural shingles (28 sq) — $7,560\n• Underlayment, flashing & ridge vent — $2,180\n• Labour (3-day crew) — $5,400\nSubtotal $19,060 · 10% margin → $20,966. Want it itemised into a sendable proposal?";
}
if (t.includes("schedule") || t.includes("crew") || t.includes("week") || t.includes("plan")) {
return "Clear weather TueThu, rain Friday. Suggested plan:\n• Tue: Crew A → 181 Sector 18 (reroof, day 1)\n• Wed: Crew A continues; Crew B → inspections in Pocket B\n• Thu: finish 181, start estimates follow-ups\nShould I block these in Team Schedule?";
}
if (t.includes("storm") || t.includes("territory") || t.includes("weather")) {
return "Last cell tracked through Sectors 14, 18 and Pocket B on 26 Jun with 1.5\" hail. 23 owners in those zones are good storm-canvass targets — 9 already have open claims. Want a canvass route?";
}
if (t.includes("pipeline") || t.includes("stuck") || t.includes("stall") || t.includes("deal")) {
return "3 deals have been idle over 14 days:\n• Bennett reroof — $21k, no contact in 18 days\n• Foster gutters — $4.2k, awaiting estimate\n• Turner inspection — booked, never closed\nThe Bennett deal is your biggest risk. Draft a re-engagement email?";
}
return "Got it. I can pull leads, build estimates, plan crew schedules, read storm/territory data and analyse your pipeline. Tell me which one to start with, or ask a specific question and I'll dig in.";
}