forked from Goutam/lynkeduppro-crm
0b76c97445
- 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>
202 lines
10 KiB
TypeScript
202 lines
10 KiB
TypeScript
"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'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 Tue–Thu, 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.";
|
||
}
|