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>
610 lines
30 KiB
TypeScript
610 lines
30 KiB
TypeScript
"use client";
|
|
|
|
// ============================================================
|
|
// Support Center
|
|
// Overview (5 channel cards + team) · Message Center ·
|
|
// New Ticket · My Tickets (+ timeline modal) · Help Center
|
|
// plus Live Chat handshake & Callback/Email modals.
|
|
// ============================================================
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
supportChannels, supportTeam, ticketCategories, tickets as ticketSeed,
|
|
supportThreads as threadSeed, helpTopics, contactTimes, countryCodes,
|
|
type Ticket, type TicketStatus, type SupportThread, type ChatMsg,
|
|
} from "./account-data";
|
|
import {
|
|
Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, StatusDot, useToast,
|
|
} from "./ui";
|
|
|
|
const SECTIONS = [
|
|
{ value: "overview", label: "Overview", icon: "chat" },
|
|
{ value: "messages", label: "Messages", icon: "mail" },
|
|
{ value: "new", label: "New Ticket", icon: "ticket" },
|
|
{ value: "tickets", label: "My Tickets", icon: "book" },
|
|
{ value: "help", label: "Help Center", icon: "info" },
|
|
];
|
|
|
|
const STATUS_META: Record<TicketStatus | "created", { label: string; tone: string }> = {
|
|
created: { label: "Created", tone: "muted" },
|
|
open: { label: "Open", tone: "blue" },
|
|
"in-progress": { label: "In Progress", tone: "orange" },
|
|
"awaiting-customer": { label: "Awaiting You", tone: "purple" },
|
|
resolved: { label: "Resolved", tone: "green" },
|
|
closed: { label: "Closed", tone: "muted" },
|
|
};
|
|
|
|
export function Support() {
|
|
const [section, setSection] = useState("overview");
|
|
const [chatOpen, setChatOpen] = useState(false);
|
|
const [callbackOpen, setCallbackOpen] = useState(false);
|
|
const [emailOpen, setEmailOpen] = useState(false);
|
|
|
|
function onChannel(id: string) {
|
|
if (id === "chat") setChatOpen(true);
|
|
else if (id === "ticket") setSection("new");
|
|
else if (id === "callback") setCallbackOpen(true);
|
|
else if (id === "email") setEmailOpen(true);
|
|
else if (id === "help") setSection("help");
|
|
}
|
|
|
|
return (
|
|
<div className="view">
|
|
<PageHead
|
|
eyebrow="Help & Support"
|
|
title="Support Center"
|
|
subtitle="Reach a human, track a request, or find an answer fast."
|
|
icon="chat"
|
|
actions={<Btn icon="chat" onClick={() => setChatOpen(true)}>Start live chat</Btn>}
|
|
/>
|
|
|
|
<Segmented options={SECTIONS} value={section} onChange={setSection} />
|
|
|
|
<div className="view-body">
|
|
{section === "overview" && <Overview onChannel={onChannel} onSection={setSection} />}
|
|
{section === "messages" && <MessageCenter />}
|
|
{section === "new" && <NewTicket onDone={() => setSection("tickets")} />}
|
|
{section === "tickets" && <MyTickets />}
|
|
{section === "help" && <HelpCenter />}
|
|
</div>
|
|
|
|
<LiveChatModal open={chatOpen} onClose={() => setChatOpen(false)} />
|
|
<CallbackModal open={callbackOpen} onClose={() => setCallbackOpen(false)} />
|
|
<EmailModal open={emailOpen} onClose={() => setEmailOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Overview — channel cards + support team */
|
|
/* ============================================================ */
|
|
|
|
const MINI_AREA: Record<string, string> = { ticket: "ticket", callback: "call", email: "email", help: "help" };
|
|
|
|
function Overview({ onChannel, onSection }: { onChannel: (id: string) => void; onSection: (s: string) => void }) {
|
|
const online = supportTeam.filter((a) => a.status === "online");
|
|
const mini = supportChannels.filter((c) => ["ticket", "callback", "email", "help"].includes(c.id));
|
|
const recent = ticketSeed.slice(0, 4);
|
|
|
|
// Derived "live" metrics so the command-center ribbon stays in sync with data.
|
|
const openCount = ticketSeed.filter((t) => !["resolved", "closed"].includes(t.status)).length;
|
|
const avgRating = (supportTeam.reduce((s, a) => s + a.rating, 0) / supportTeam.length).toFixed(1);
|
|
const pulse = [
|
|
{ icon: "people", value: String(online.length), label: "Agents online", live: true },
|
|
{ icon: "clock", value: "< 2m", label: "Avg wait time" },
|
|
{ icon: "ticket", value: String(openCount), label: "Open tickets" },
|
|
{ icon: "star", value: avgRating, label: "Avg CSAT rating" },
|
|
];
|
|
|
|
return (
|
|
<div className="support-ov">
|
|
{/* Live command-center ribbon */}
|
|
<div className="sup-status">
|
|
<span className="sup-status-l"><span className="sup-status-dot" /> All systems operational</span>
|
|
<span className="sup-status-r">Support · Live status</span>
|
|
</div>
|
|
<div className="sup-pulse">
|
|
{pulse.map((p) => (
|
|
<div className="sup-stat" key={p.label}>
|
|
<span className="sup-stat-ic"><Icon name={p.icon} size={17} /></span>
|
|
<div className="sup-stat-txt">
|
|
<b>{p.value}{p.icon === "star" && <Icon name="star" size={12} />}</b>
|
|
<span>{p.label}</span>
|
|
</div>
|
|
{p.live && <span className="sup-stat-live" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="support-bento">
|
|
{/* Hero — live chat */}
|
|
<button className="bento-tile bento-chat" onClick={() => onChannel("chat")}>
|
|
<span className="bento-chat-sig" aria-hidden><span /><span /><span /></span>
|
|
<div className="bento-chat-top">
|
|
<span className="bento-chat-badge"><StatusDot status="online" /> Agents online now</span>
|
|
<span className="bento-chat-wait"><Icon name="clock" size={13} /> Avg wait < 2 min</span>
|
|
</div>
|
|
<div className="bento-chat-mid">
|
|
<h2>Need a hand?<br />Chat with us live.</h2>
|
|
<p>Instant help from a real support specialist — no queues, no bots, no waiting on hold.</p>
|
|
</div>
|
|
<div className="bento-chat-foot">
|
|
<div className="bento-avstack">
|
|
{online.map((a) => <Avatar key={a.id} initials={a.initials} gradient={a.gradient} size={36} />)}
|
|
<span className="bento-av-more">+{online.length} online</span>
|
|
</div>
|
|
<span className="bento-chat-cta">Start chat <Icon name="arrow" size={16} /></span>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Support team */}
|
|
<div className="bento-tile bento-team">
|
|
<div className="bento-tile-head"><h3>Your support team</h3><Pill tone="green">{online.length} online</Pill></div>
|
|
<div className="team-list">
|
|
{supportTeam.map((a) => (
|
|
<div className="team-row" key={a.id}>
|
|
<Avatar initials={a.initials} gradient={a.gradient} size={40} status={a.status} />
|
|
<div className="team-txt">
|
|
<div className="team-name">{a.name}</div>
|
|
<div className="team-role">{a.role}</div>
|
|
<div className="team-team"><Icon name="shield" size={11} /> {a.team}</div>
|
|
</div>
|
|
<div className="team-rating"><Icon name="star" size={13} /> {a.rating}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mini channel tiles */}
|
|
{mini.map((c) => (
|
|
<button key={c.id} className="bento-tile bento-mini" style={{ ["--accent" as string]: c.accent, gridArea: MINI_AREA[c.id] }} onClick={() => onChannel(c.id)}>
|
|
<span className="bento-mini-ic"><Icon name={c.icon} size={20} /></span>
|
|
<div className="bento-mini-title">{c.title}</div>
|
|
<div className="bento-mini-desc">{c.desc}</div>
|
|
<div className="bento-mini-meta"><span>{c.meta}</span> <Icon name="arrow" size={14} /></div>
|
|
</button>
|
|
))}
|
|
|
|
{/* Recent tickets */}
|
|
<div className="bento-tile bento-recent">
|
|
<div className="bento-tile-head"><h3>Recent tickets</h3><button className="link-inline" onClick={() => onSection("tickets")}>View all</button></div>
|
|
<div className="bento-ticket-row">
|
|
{recent.map((t) => (
|
|
<button key={t.id} className="bento-ticket" onClick={() => onSection("tickets")}>
|
|
<div className="bento-ticket-top"><span className="t-id">{t.id}</span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></div>
|
|
<div className="bento-ticket-subj">{t.subject}</div>
|
|
<div className="bento-ticket-meta"><Icon name="clock" size={12} /> {t.updatedAt} · {t.department}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Message Center — inbox + live thread + typing */
|
|
/* ============================================================ */
|
|
|
|
function MessageCenter() {
|
|
const [threads, setThreads] = useState<SupportThread[]>(threadSeed);
|
|
const [activeId, setActiveId] = useState(threadSeed[0].id);
|
|
const [draft, setDraft] = useState("");
|
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
const active = threads.find((t) => t.id === activeId)!;
|
|
|
|
function send() {
|
|
const text = draft.trim();
|
|
if (!text) return;
|
|
const msg: ChatMsg = { id: `m${Date.now()}`, from: "me", text, at: "Now" };
|
|
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, msg], typing: true, preview: text } : t));
|
|
setDraft("");
|
|
// simulate agent typing → reply
|
|
setTimeout(() => {
|
|
const reply: ChatMsg = { id: `m${Date.now() + 1}`, from: "agent", text: "Thanks — noted. I'll update the ticket and get back to you shortly.", at: "Now" };
|
|
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, reply], typing: false } : t));
|
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, 1800);
|
|
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
|
}
|
|
|
|
function openThread(id: string) {
|
|
setActiveId(id);
|
|
setThreads((ts) => ts.map((t) => t.id === id ? { ...t, unread: 0 } : t));
|
|
}
|
|
|
|
const agent = supportTeam.find((a) => a.id === active.agentId)!;
|
|
|
|
return (
|
|
<div className="card card-pad-0 msgctr">
|
|
<aside className="msg-inbox">
|
|
<div className="msg-inbox-head">Inbox <Pill tone="orange">{threads.reduce((n, t) => n + t.unread, 0)}</Pill></div>
|
|
<div className="msg-inbox-list">
|
|
{[...threads].sort((a, b) => Number(b.pinned) - Number(a.pinned)).map((t) => {
|
|
const ta = supportTeam.find((x) => x.id === t.agentId)!;
|
|
return (
|
|
<button key={t.id} className={`msg-thread ${t.id === activeId ? "active" : ""}`} onClick={() => openThread(t.id)}>
|
|
<Avatar initials={ta.initials} gradient={ta.gradient} size={38} status={ta.status} />
|
|
<div className="msg-thread-txt">
|
|
<div className="msg-thread-top"><span className="msg-thread-name">{ta.name}</span><span className="msg-thread-time">{t.updatedAt}</span></div>
|
|
<div className="msg-thread-sub">{t.subject}</div>
|
|
<div className="msg-thread-prev">{t.typing ? <em className="typing-now">typing…</em> : t.preview}</div>
|
|
</div>
|
|
{t.unread > 0 && <span className="msg-unread">{t.unread}</span>}
|
|
{t.pinned && <Icon name="check" size={12} className="msg-pin" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="msg-thread-pane">
|
|
<header className="msg-pane-head">
|
|
<Avatar initials={agent.initials} gradient={agent.gradient} size={40} status={agent.status} />
|
|
<div className="msg-pane-id">
|
|
<div className="msg-pane-name">{agent.name}</div>
|
|
<div className="msg-pane-sub">{active.typing ? <span className="typing-now">typing…</span> : `${agent.role} · ${agent.team}`}</div>
|
|
</div>
|
|
<Pill tone="blue">{active.subject}</Pill>
|
|
</header>
|
|
|
|
<div className="msg-stream">
|
|
{active.messages.map((m) => (
|
|
<div key={m.id} className={`bubble-row ${m.from}`}>
|
|
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={28} />}
|
|
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
|
|
</div>
|
|
))}
|
|
{active.typing && (
|
|
<div className="bubble-row agent">
|
|
<Avatar initials={agent.initials} gradient={agent.gradient} size={28} />
|
|
<div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div>
|
|
</div>
|
|
)}
|
|
<div ref={endRef} />
|
|
</div>
|
|
|
|
<div className="msg-compose">
|
|
<button className="ds-iconbtn" aria-label="Attach"><Icon name="paperclip" size={18} /></button>
|
|
<input className="ds-input flush" placeholder="Write a reply…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
|
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* New Ticket — category → department routing + attachment rules */
|
|
/* ============================================================ */
|
|
|
|
function NewTicket({ onDone }: { onDone: () => void }) {
|
|
const { push } = useToast();
|
|
const [catId, setCatId] = useState("");
|
|
const [subject, setSubject] = useState("");
|
|
const [desc, setDesc] = useState("");
|
|
const [priority, setPriority] = useState("medium");
|
|
const [file, setFile] = useState<string | null>(null);
|
|
|
|
const cat = ticketCategories.find((c) => c.id === catId) ?? null;
|
|
const needAttachment = cat?.requiresAttachment && !file;
|
|
const canSubmit = catId && subject.trim() && desc.trim() && !needAttachment;
|
|
|
|
function submit() {
|
|
push({ tone: "success", title: "Ticket created", desc: `Routed to ${cat?.department}. We'll reply within ${cat?.sla}.` });
|
|
onDone();
|
|
}
|
|
|
|
return (
|
|
<div className="grid-side">
|
|
<div className="card">
|
|
<div className="card-head"><h3>Raise a new ticket</h3></div>
|
|
|
|
<Field label="What is this about?" required>
|
|
<select className="ds-select" value={catId} onChange={(e) => { setCatId(e.target.value); setFile(null); }}>
|
|
<option value="" disabled>Select a category…</option>
|
|
{ticketCategories.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
|
</select>
|
|
</Field>
|
|
|
|
{cat && (
|
|
<div className="route-banner">
|
|
<Icon name="arrow" size={15} />
|
|
<span>Routes to <strong>{cat.department}</strong></span>
|
|
<Pill tone="blue"><Icon name="clock" size={12} /> SLA {cat.sla}</Pill>
|
|
</div>
|
|
)}
|
|
|
|
<Field label="Subject" required><input className="ds-input" placeholder="Short summary" value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
|
|
<Field label="Describe the issue" required><textarea className="ds-input ds-textarea" rows={4} placeholder="Add as much detail as you can…" value={desc} onChange={(e) => setDesc(e.target.value)} /></Field>
|
|
|
|
<Field label="Priority">
|
|
<Segmented value={priority} onChange={setPriority} options={[
|
|
{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" },
|
|
]} />
|
|
</Field>
|
|
|
|
{cat && (
|
|
<Field label={`Attachment${cat.requiresAttachment ? "" : " (optional)"}`} required={cat.requiresAttachment} error={needAttachment ? "This category requires an attachment." : undefined} hint={cat.attachmentNote}>
|
|
{file ? (
|
|
<div className="kyc-file"><Icon name="paperclip" size={14} /><span className="kyc-file-name">{file}</span><button className="ds-iconbtn sm" aria-label="Remove" onClick={() => setFile(null)}><Icon name="trash" size={14} /></button></div>
|
|
) : (
|
|
<button className="kyc-drop" onClick={() => setFile("attachment.pdf")}><Icon name="upload" size={16} /> Attach a file</button>
|
|
)}
|
|
</Field>
|
|
)}
|
|
|
|
<div className="card-actions"><Btn icon="send" disabled={!canSubmit} onClick={submit}>Submit ticket</Btn></div>
|
|
</div>
|
|
|
|
<div className="card card-muted">
|
|
<div className="card-head"><h3>How routing works</h3></div>
|
|
<div className="route-list">
|
|
{ticketCategories.map((c) => (
|
|
<div className="route-item" key={c.id}>
|
|
<div className="route-cat">{c.label}</div>
|
|
<Icon name="arrow" size={13} />
|
|
<div className="route-dept">{c.department}</div>
|
|
{c.requiresAttachment && <Pill tone="orange"><Icon name="paperclip" size={11} /> Attach</Pill>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* My Tickets — table + status timeline modal */
|
|
/* ============================================================ */
|
|
|
|
function MyTickets() {
|
|
const [filter, setFilter] = useState("all");
|
|
const [open, setOpen] = useState<Ticket | null>(null);
|
|
const list = useMemo(() => filter === "all" ? ticketSeed : ticketSeed.filter((t) => filter === "active" ? !["resolved", "closed"].includes(t.status) : ["resolved", "closed"].includes(t.status)), [filter]);
|
|
|
|
return (
|
|
<div className="card card-pad-0">
|
|
<div className="card-head pad">
|
|
<h3>My tickets</h3>
|
|
<Segmented value={filter} onChange={setFilter} options={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "closed", label: "Closed" }]} />
|
|
</div>
|
|
<div className="ticket-table">
|
|
<div className="ticket-row ticket-head">
|
|
<span>Ticket</span><span>Department</span><span>Priority</span><span>Status</span><span>Updated</span>
|
|
</div>
|
|
{list.map((t) => (
|
|
<button key={t.id} className="ticket-row" onClick={() => setOpen(t)}>
|
|
<span className="t-subject"><span className="t-id">{t.id}</span>{t.subject}</span>
|
|
<span className="t-dept">{t.department}</span>
|
|
<span><Pill tone={t.priority === "urgent" ? "red" : t.priority === "high" ? "orange" : t.priority === "medium" ? "blue" : "muted"}>{t.priority}</Pill></span>
|
|
<span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></span>
|
|
<span className="t-updated">{t.updatedAt} <Icon name="chevron-right" size={14} /></span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<Modal open={open != null} onClose={() => setOpen(null)} icon="ticket" size="md" title={open?.subject ?? ""} subtitle={open ? `${open.id} · ${open.department}` : ""}
|
|
footer={<><Btn variant="ghost" onClick={() => setOpen(null)}>Close</Btn><Btn icon="chat">Reply in chat</Btn></>}>
|
|
{open && (
|
|
<>
|
|
<div className="ticket-modal-meta">
|
|
<Pill tone={STATUS_META[open.status].tone}>{STATUS_META[open.status].label}</Pill>
|
|
<Pill tone="muted">Priority: {open.priority}</Pill>
|
|
<Pill tone="muted">Opened {open.createdAt}</Pill>
|
|
</div>
|
|
<div className="lifecycle">
|
|
{(["open", "in-progress", "awaiting-customer", "resolved", "closed"] as TicketStatus[]).map((s, i) => {
|
|
const reached = open.timeline.some((e) => e.status === s) || (s === "open");
|
|
return <span key={s} className={`lc-step ${reached ? "done" : ""} ${open.status === s ? "current" : ""}`}>{i > 0 && <span className="lc-line" />}<span className="lc-dot" />{STATUS_META[s].label}</span>;
|
|
})}
|
|
</div>
|
|
<div className="timeline">
|
|
{open.timeline.slice().reverse().map((e, i) => (
|
|
<div className="tl-item" key={i}>
|
|
<span className={`tl-dot ev-${e.status}`}><Icon name={e.status === "resolved" || e.status === "closed" ? "check" : e.status === "awaiting-customer" ? "alert" : "chat"} size={12} /></span>
|
|
<div className="tl-body"><div className="tl-title">{e.label}</div><div className="tl-meta">{e.by}</div><div className="tl-time">{e.at}</div></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Help Center — search + browse accordion */
|
|
/* ============================================================ */
|
|
|
|
function HelpCenter() {
|
|
const [q, setQ] = useState("");
|
|
const [openId, setOpenId] = useState<string | null>(helpTopics[0].id);
|
|
const query = q.trim().toLowerCase();
|
|
|
|
const results = useMemo(() => {
|
|
if (!query) return null;
|
|
const hits: { topic: string; q: string; a: string }[] = [];
|
|
for (const t of helpTopics) for (const a of t.articles) if ((a.q + a.a).toLowerCase().includes(query)) hits.push({ topic: t.title, ...a });
|
|
return hits;
|
|
}, [query]);
|
|
|
|
return (
|
|
<div className="view">
|
|
<div className="help-search">
|
|
<Icon name="search" size={18} />
|
|
<input className="ds-input flush" placeholder="Search guides, FAQs and how-tos…" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
{q && <button className="ds-iconbtn sm" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
|
|
</div>
|
|
|
|
{results ? (
|
|
<div className="card">
|
|
<div className="card-head"><h3>{results.length} result{results.length === 1 ? "" : "s"} for “{q}”</h3></div>
|
|
{results.length === 0 ? <p className="muted-note"><Icon name="info" size={14} /> Nothing matched. Try different keywords or raise a ticket.</p> : (
|
|
<div className="help-results">
|
|
{results.map((r, i) => (
|
|
<details key={i} className="help-acc">
|
|
<summary><span className="help-q">{r.q}</span><Pill tone="muted">{r.topic}</Pill><Icon name="chevron" size={16} className="acc-chev" /></summary>
|
|
<p>{r.a}</p>
|
|
</details>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="help-topics">
|
|
{helpTopics.map((t) => (
|
|
<div className="card help-topic-card" key={t.id} style={{ ["--accent" as string]: t.accent }}>
|
|
<button className="help-topic-head" onClick={() => setOpenId(openId === t.id ? null : t.id)}>
|
|
<span className="help-topic-ic"><Icon name={t.icon} size={20} /></span>
|
|
<div className="help-topic-txt"><div className="help-topic-title">{t.title}</div><div className="help-topic-count">{t.count} articles</div></div>
|
|
<Icon name="chevron" size={18} className={`acc-chev ${openId === t.id ? "open" : ""}`} />
|
|
</button>
|
|
{openId === t.id && (
|
|
<div className="help-acc-list">
|
|
{t.articles.map((a, i) => (
|
|
<details key={i} className="help-acc">
|
|
<summary><span className="help-q">{a.q}</span><Icon name="chevron" size={15} className="acc-chev" /></summary>
|
|
<p>{a.a}</p>
|
|
</details>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Live Chat — connecting → joined handshake */
|
|
/* ============================================================ */
|
|
|
|
function LiveChatModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const [phase, setPhase] = useState<"connecting" | "joined">("connecting");
|
|
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [agentTyping, setAgentTyping] = useState(false);
|
|
const agent = supportTeam[0];
|
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
|
|
// run the connecting → joined handshake each time the modal opens.
|
|
// Resetting + timed phase changes is an animation driven by `open`, so the
|
|
// synchronous resets here are intentional.
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setPhase("connecting");
|
|
setMsgs([]);
|
|
setDraft("");
|
|
setAgentTyping(false);
|
|
const t1 = setTimeout(() => {
|
|
setPhase("joined");
|
|
setAgentTyping(true);
|
|
const t2 = setTimeout(() => {
|
|
setAgentTyping(false);
|
|
setMsgs([{ id: "g1", from: "agent", text: `Hi! I'm ${agent.name} from ${agent.team}. How can I help you today?`, at: "Now" }]);
|
|
}, 1400);
|
|
timers.current.push(t2);
|
|
}, 2000);
|
|
timers.current.push(t1);
|
|
const timersRef = timers.current;
|
|
return () => { timersRef.forEach(clearTimeout); timers.current = []; };
|
|
}, [open, agent.name, agent.team]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
function send() {
|
|
const text = draft.trim();
|
|
if (!text) return;
|
|
setMsgs((m) => [...m, { id: `u${Date.now()}`, from: "me", text, at: "Now" }]);
|
|
setDraft("");
|
|
setAgentTyping(true);
|
|
setTimeout(() => {
|
|
setAgentTyping(false);
|
|
setMsgs((m) => [...m, { id: `a${Date.now()}`, from: "agent", text: "Got it — let me pull up your account and check that for you.", at: "Now" }]);
|
|
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
|
|
}, 1600);
|
|
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} size="md" icon="chat" title="Live Chat"
|
|
subtitle={phase === "connecting" ? "Connecting you to the next available agent…" : `Connected · ${agent.name}`}>
|
|
{phase === "connecting" ? (
|
|
<div className="chat-connecting">
|
|
<div className="chat-radar"><Avatar initials={agent.initials} gradient={agent.gradient} size={64} /><span className="radar-ring" /><span className="radar-ring d2" /></div>
|
|
<div className="chat-connecting-txt">Finding an agent<span className="dots"><span>.</span><span>.</span><span>.</span></span></div>
|
|
<div className="muted-note">Average wait under 2 minutes</div>
|
|
</div>
|
|
) : (
|
|
<div className="livechat">
|
|
<div className="livechat-joined"><StatusDot status="online" /> {agent.name} joined the chat</div>
|
|
<div className="msg-stream sm">
|
|
{msgs.map((m) => (
|
|
<div key={m.id} className={`bubble-row ${m.from}`}>
|
|
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={26} />}
|
|
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
|
|
</div>
|
|
))}
|
|
{agentTyping && <div className="bubble-row agent"><Avatar initials={agent.initials} gradient={agent.gradient} size={26} /><div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div>}
|
|
<div ref={endRef} />
|
|
</div>
|
|
<div className="msg-compose">
|
|
<input className="ds-input flush" placeholder="Type a message…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
|
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Callback & Email modals */
|
|
/* ============================================================ */
|
|
|
|
function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const { push } = useToast();
|
|
const [cc, setCc] = useState("+91");
|
|
const [num, setNum] = useState("98765 43012");
|
|
const [slot, setSlot] = useState(contactTimes.find((t) => t.enabled)?.id ?? contactTimes[0].id);
|
|
return (
|
|
<Modal open={open} onClose={onClose} icon="phone" title="Request a callback" subtitle="We'll call within your chosen contact-time window."
|
|
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="phone" onClick={() => { push({ tone: "success", title: "Callback scheduled", desc: "An agent will call you in your selected window." }); onClose(); }}>Schedule callback</Btn></>}>
|
|
<Field label="Phone number">
|
|
<div className="phone-input">
|
|
<select className="ds-select cc" value={cc} onChange={(e) => setCc(e.target.value)}>{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}</select>
|
|
<input className="ds-input flush" value={num} onChange={(e) => setNum(e.target.value)} />
|
|
</div>
|
|
</Field>
|
|
<Field label="Preferred window" hint="Only windows enabled in your notification preferences are offered.">
|
|
<div className="cb-slots">
|
|
{contactTimes.filter((t) => t.enabled).map((t) => (
|
|
<button key={t.id} className={`cb-slot ${slot === t.id ? "active" : ""}`} onClick={() => setSlot(t.id)}>
|
|
<span className="cb-slot-l">{t.label}</span><span className="cb-slot-r">{t.range}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function EmailModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const { push } = useToast();
|
|
const [subject, setSubject] = useState("");
|
|
const [body, setBody] = useState("");
|
|
return (
|
|
<Modal open={open} onClose={onClose} icon="mail" title="Email support" subtitle="Sends to care@lynkeduppro.com · reply within 1 business day."
|
|
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="send" disabled={!subject.trim() || !body.trim()} onClick={() => { push({ tone: "success", title: "Email sent", desc: "We'll reply to your registered email." }); onClose(); }}>Send email</Btn></>}>
|
|
<Field label="Subject"><input className="ds-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="How can we help?" /></Field>
|
|
<Field label="Message"><textarea className="ds-input ds-textarea" rows={5} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" /></Field>
|
|
</Modal>
|
|
);
|
|
}
|