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>
This commit is contained in:
2026-06-30 14:40:29 +05:30
parent d7eb82f182
commit 0b76c97445
10 changed files with 2045 additions and 758 deletions
+201
View File
@@ -0,0 +1,201 @@
"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.";
}