first commit

This commit is contained in:
2026-07-17 21:48:37 +05:30
commit f85599dac5
124 changed files with 10775 additions and 0 deletions
+264
View File
@@ -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>
);
}