first commit
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useTickets,
|
||||
useAvailability,
|
||||
useSdk,
|
||||
useFeatureFlags,
|
||||
type Ticket,
|
||||
type TicketState,
|
||||
type TicketPriority,
|
||||
} from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Header } from "@/components/nav/Header";
|
||||
import { Avatar, Chip } from "@/components/ui/primitives";
|
||||
import { RichText } from "@/lib/rich-text";
|
||||
import { clockTime, relativeTime } from "@/lib/format";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
LifeBuoy, AlertTriangle, Circle, Send, ArrowUpCircle, CheckCircle2,
|
||||
PhoneCall, Tag, Clock, Search, ChevronLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
const stateTone: Record<TicketState, Parameters<typeof Chip>[0]["tone"]> = {
|
||||
NEW: "info", OPEN: "accent", PENDING: "warning", RESOLVED: "success", CLOSED: "neutral",
|
||||
};
|
||||
const priTone: Record<TicketPriority, Parameters<typeof Chip>[0]["tone"]> = {
|
||||
LOW: "neutral", NORMAL: "info", HIGH: "warning", URGENT: "danger",
|
||||
};
|
||||
|
||||
export function SupportView({ initialTicketId }: { initialTicketId?: string }) {
|
||||
const { tickets, loading, patch, reply } = useTickets("all");
|
||||
const { toast, openModal } = useUi();
|
||||
const [selected, setSelected] = useState<string | null>(initialTicketId ?? null);
|
||||
const [scope, setScope] = useState<TicketState | "ALL">("ALL");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTicketId) setSelected(initialTicketId);
|
||||
}, [initialTicketId]);
|
||||
// Desktop shows both panes, so default-select the first ticket. On mobile,
|
||||
// don't auto-reselect — that would defeat the "Back to list" button.
|
||||
useEffect(() => {
|
||||
if (!selected && tickets.length && typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches) {
|
||||
setSelected(tickets[0].id);
|
||||
}
|
||||
}, [tickets, selected]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => tickets.filter((t) =>
|
||||
(scope === "ALL" || t.state === scope) &&
|
||||
(!q || t.subject.toLowerCase().includes(q.toLowerCase()) || t.ref.toLowerCase().includes(q.toLowerCase()))),
|
||||
[tickets, scope, q],
|
||||
);
|
||||
|
||||
const selectedTicket = tickets.find((t) => t.id === selected) ?? null;
|
||||
const open = tickets.filter((t) => t.state === "OPEN" || t.state === "NEW").length;
|
||||
const breached = tickets.filter((t) => t.slaBreached).length;
|
||||
const resolved7d = tickets.filter((t) => (t.state === "RESOLVED" || t.state === "CLOSED")).length;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header eyebrow="Workspace" title="Support Center" subtitle="Tickets, escalations, callbacks and agent availability">
|
||||
<AvailabilityPill />
|
||||
</Header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-b border-border p-4 sm:grid-cols-4">
|
||||
<Stat label="Open tickets" value={open} icon={LifeBuoy} tone="accent" />
|
||||
<Stat label="SLA breached" value={breached} icon={AlertTriangle} tone="danger" />
|
||||
<Stat label="Resolved / closed" value={resolved7d} icon={CheckCircle2} tone="success" />
|
||||
<Stat label="Avg. first response" value="18m" icon={Clock} tone="info" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* list */}
|
||||
<div className={clsx("w-full shrink-0 flex-col border-r border-border md:flex md:w-[340px] lg:w-[380px]", selected ? "hidden md:flex" : "flex")}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-control border border-border bg-panel px-2.5 py-1.5">
|
||||
<Search size={14} className="text-muted" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search tickets" className="w-full bg-transparent text-[13px] outline-none placeholder:text-dim" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto no-scrollbar border-b border-border px-3 py-2">
|
||||
{(["ALL", "NEW", "OPEN", "PENDING", "RESOLVED", "CLOSED"] as const).map((s) => (
|
||||
<button key={s} onClick={() => setScope(s)} className={clsx("whitespace-nowrap rounded-pill border px-2.5 py-1 text-[11.5px] font-semibold capitalize", scope === s ? "border-accent text-accent" : "border-border text-muted hover:text-ink")}>
|
||||
{s.toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{loading && <div className="p-6 text-center text-muted">Loading…</div>}
|
||||
{filtered.map((t) => <TicketRow key={t.id} ticket={t} active={t.id === selected} onClick={() => setSelected(t.id)} />)}
|
||||
{!loading && filtered.length === 0 && <div className="p-6 text-center text-[13px] text-muted">No tickets match.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* detail */}
|
||||
<div className={clsx("min-w-0 flex-1", selected ? "flex" : "hidden md:flex")}>
|
||||
{selectedTicket ? (
|
||||
<TicketDetail
|
||||
key={selectedTicket.id}
|
||||
ticket={selectedTicket}
|
||||
onBack={() => setSelected(null)}
|
||||
onSetState={(s) => patch(selectedTicket.id, s)}
|
||||
onReply={(b) => reply(selectedTicket.id, b)}
|
||||
onEscalate={() => { patch(selectedTicket.id, "OPEN"); toast(`${selectedTicket.ref} escalated to Tier 2`, "success"); }}
|
||||
onCallback={() => openModal("callback")}
|
||||
/>
|
||||
) : <EmptyDetail />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketRow({ ticket, active, onClick }: { ticket: Ticket; active: boolean; onClick: () => void }) {
|
||||
const { userById } = useSdk();
|
||||
const requester = userById(ticket.requesterId);
|
||||
return (
|
||||
<button onClick={onClick} className={clsx("flex w-full flex-col gap-1.5 border-b border-border px-3.5 py-3 text-left transition-colors", active ? "bg-accent-soft/50" : "hover:bg-hover")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[11px] text-dim">{ticket.ref}</span>
|
||||
<Chip tone={priTone[ticket.priority]}>{ticket.priority}</Chip>
|
||||
{ticket.slaBreached && <Chip tone="danger">SLA</Chip>}
|
||||
<span className="ml-auto text-[11px] text-dim">{relativeTime(ticket.updatedTs)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[14px] font-semibold">{ticket.subject}</div>
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted">
|
||||
{requester && <Avatar user={requester} size={16} showPresence={false} rounded="5px" />}
|
||||
<span className="min-w-0 truncate">{requester?.name}</span>
|
||||
<Chip tone={stateTone[ticket.state]}>{ticket.state}</Chip>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketDetail({
|
||||
ticket, onBack, onSetState, onReply, onEscalate, onCallback,
|
||||
}: {
|
||||
ticket: Ticket;
|
||||
onBack: () => void;
|
||||
onSetState: (s: TicketState) => void;
|
||||
onReply: (body: string) => void;
|
||||
onEscalate: () => void;
|
||||
onCallback: () => void;
|
||||
}) {
|
||||
const { userById } = useSdk();
|
||||
const flags = useFeatureFlags();
|
||||
const [draft, setDraft] = useState("");
|
||||
const requester = userById(ticket.requesterId);
|
||||
const assignee = ticket.assigneeId ? userById(ticket.assigneeId) : undefined;
|
||||
|
||||
const send = () => { if (draft.trim()) { onReply(draft.trim()); setDraft(""); } };
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="shrink-0 border-b border-border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onBack} className="focus-ring -ml-1 mr-1 grid h-7 w-7 place-items-center rounded-control text-muted hover:bg-hover md:hidden" aria-label="Back to list"><ChevronLeft size={18} /></button>
|
||||
<span className="font-mono text-[12px] text-dim">{ticket.ref}</span>
|
||||
<Chip tone={stateTone[ticket.state]}>{ticket.state}</Chip>
|
||||
<Chip tone={priTone[ticket.priority]}>{ticket.priority}</Chip>
|
||||
{ticket.slaBreached && <Chip tone="danger"><AlertTriangle size={11} /> SLA breached</Chip>}
|
||||
</div>
|
||||
<h2 className="mt-2 text-[18px] font-black">{ticket.subject}</h2>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
|
||||
{requester && <span className="flex items-center gap-1.5"><Avatar user={requester} size={18} showPresence={false} rounded="5px" /> {requester.name}</span>}
|
||||
{assignee && <span>· Assigned to <b className="text-ink">{assignee.name}</b></span>}
|
||||
{ticket.category && <span className="flex items-center gap-1"><Tag size={12} /> {ticket.category}</span>}
|
||||
<span>· opened {relativeTime(ticket.createdTs)}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{flags.supportEscalate && <ActionBtn icon={ArrowUpCircle} label="Escalate" onClick={onEscalate} />}
|
||||
<ActionBtn icon={CheckCircle2} label="Resolve" accent onClick={() => onSetState("RESOLVED")} />
|
||||
<ActionBtn icon={Circle} label="Pending" onClick={() => onSetState("PENDING")} />
|
||||
{flags.supportCallback && <ActionBtn icon={PhoneCall} label="Request callback" onClick={onCallback} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
{ticket.messages?.map((m) => {
|
||||
const author = userById(m.authorId);
|
||||
const mine = m.authorId === "u_me";
|
||||
return (
|
||||
<div key={m.id} className={clsx("flex gap-3", mine && "flex-row-reverse")}>
|
||||
{author && <Avatar user={author} size={32} showPresence={false} />}
|
||||
<div className={clsx("max-w-[80%] rounded-card border border-border p-3", mine ? "bg-accent-soft" : "bg-panel")}>
|
||||
<div className="mb-1 flex items-center gap-2 text-[12px]"><b>{author?.name}</b><span className="text-dim">{clockTime(m.ts)}</span></div>
|
||||
<div className="text-[14px] leading-relaxed text-ink/90"><RichText text={m.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticket.state !== "CLOSED" && (
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
<div className="flex items-end gap-2 rounded-card border border-border bg-panel p-2 focus-within:border-border-strong">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !(e.nativeEvent as any).isComposing) { e.preventDefault(); send(); } }}
|
||||
rows={2}
|
||||
placeholder={`Reply to ${requester?.name ?? "customer"}…`}
|
||||
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[14px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
<button onClick={send} disabled={!draft.trim()} className="focus-ring mb-1 mr-1 flex items-center gap-1.5 rounded-control bg-accent px-3 py-2 text-[13px] font-semibold text-accent-fg disabled:opacity-40">
|
||||
<Send size={14} /> Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailabilityPill() {
|
||||
const flags = useFeatureFlags();
|
||||
const { toast } = useUi();
|
||||
const agents = useAvailability();
|
||||
const [online, setOnline] = useState(true);
|
||||
if (!flags.supportAvailability) return null;
|
||||
const onlineCount = agents.filter((a) => a.online).length + (online ? 1 : 0);
|
||||
return (
|
||||
<button
|
||||
onClick={() => { setOnline((o) => { const n = !o; toast(n ? "You're online for support" : "You're offline", n ? "success" : "default"); return n; }); }}
|
||||
className="mr-1 flex items-center gap-2 rounded-control border border-border bg-panel px-3 py-1.5 text-[12.5px] font-semibold hover:bg-hover"
|
||||
title="Toggle your availability"
|
||||
>
|
||||
<span className={clsx("h-2.5 w-2.5 rounded-full", online ? "bg-success" : "bg-dim")} />
|
||||
{online ? "Online" : "Offline"}
|
||||
<span className="text-muted">· {onlineCount} agents</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon: Icon, tone }: { label: string; value: React.ReactNode; icon: typeof LifeBuoy; tone: string }) {
|
||||
const toneBg: Record<string, string> = { accent: "var(--c-accent-soft)", danger: "rgba(242,85,90,0.12)", success: "rgba(62,207,142,0.12)", info: "rgba(91,157,255,0.12)" };
|
||||
const toneFg: Record<string, string> = { accent: "var(--c-accent)", danger: "var(--c-danger)", success: "var(--c-success)", info: "var(--c-info)" };
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-card border border-border bg-panel p-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-control" style={{ background: toneBg[tone], color: toneFg[tone] }}><Icon size={18} /></span>
|
||||
<div><div className="text-[20px] font-black leading-none">{value}</div><div className="mt-1 text-[11.5px] text-muted">{label}</div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({ icon: Icon, label, onClick, accent }: { icon: typeof Send; label: string; onClick: () => void; accent?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick} className={clsx("focus-ring flex items-center gap-1.5 rounded-control border px-3 py-1.5 text-[12.5px] font-semibold transition-colors", accent ? "border-accent bg-accent text-accent-fg" : "border-border hover:bg-hover")}>
|
||||
<Icon size={14} /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDetail() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted">
|
||||
<div className="text-center"><LifeBuoy size={36} className="mx-auto mb-2 text-dim" /><div className="text-[14px]">Select a ticket to view the conversation</div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user