Maintains a clear handling path across queue, conversation, and customer context.
@motion-lexicon
src/registry/blocks/support-inbox.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Drawer } from "@/components/motion-lexicon/drawer";
import { InlineEdit } from "@/components/motion-lexicon/inline-edit";
import { LoadingButton } from "@/components/motion-lexicon/loading-button";
import { MetricTicker } from "@/components/motion-lexicon/metric-ticker";
type Locale = "zh" | "en";
type SendState = "idle" | "sending" | "sent";
export type SupportInboxBlockProps = {
locale?: Locale;
className?: string;
};
const ease = [0.23, 1, 0.32, 1] as const;
export function SupportInboxBlock({ locale = "en", className = "" }: SupportInboxBlockProps) {
const reduced = useReducedMotion();
const copy = locale === "zh" ? zh : en;
const [activeId, setActiveId] = useState(copy.conversations[0].id);
const [reply, setReply] = useState("");
const [sendState, setSendState] = useState<SendState>("idle");
const [detailsOpen, setDetailsOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const active = copy.conversations.find((conversation) => conversation.id === activeId) ?? copy.conversations[0];
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
function sendReply() {
if (!reply.trim() || sendState === "sending") return;
if (timer.current) clearTimeout(timer.current);
setSendState("sending");
timer.current = setTimeout(() => {
setSendState("sent");
setReply("");
timer.current = setTimeout(() => setSendState("idle"), 1200);
}, reduced ? 0 : 720);
}
return (
<section className={`min-h-[720px] w-full overflow-hidden rounded-[18px] border border-[#d8cfc3]/60 bg-[#f4f0ea] text-neutral-950 shadow-[0_28px_60px_-42px_rgba(63,49,34,.38)] dark:border-white/10 dark:bg-[#151515] dark:text-neutral-50 ${className}`} data-page-block="support-inbox">
<header className="flex min-h-14 items-center justify-between gap-4 border-b border-[#d8cfc3]/60 bg-[#fffcf7] px-4 sm:px-6 dark:border-white/10 dark:bg-[#1b1b1b]">
<a className="flex min-h-11 items-center gap-2.5 rounded-lg font-semibold tracking-[-.02em] focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" href="#relay-inbox">
<span className="grid size-8 place-items-center rounded-[10px] bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950" aria-hidden="true"><svg viewBox="0 0 24 24" className="size-4 fill-none stroke-current" strokeWidth="1.8"><path d="M5 6h14v10H9l-4 3V6Z" /><path d="M8 10h8M8 13h5" /></svg></span>
Relay
</a>
<div className="hidden min-w-0 flex-1 justify-center md:flex"><label className="flex min-h-11 w-full max-w-sm items-center gap-2 rounded-lg border border-neutral-900/10 bg-white px-3 text-[11px] text-neutral-400 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 dark:border-white/10 dark:bg-white/5"><span aria-hidden="true">⌕</span><input className="min-w-0 flex-1 bg-transparent text-[16px] text-neutral-950 outline-none placeholder:text-neutral-400 md:text-[11px] dark:text-white" placeholder={copy.search} /></label></div>
<button className="min-h-11 rounded-lg border border-neutral-300 bg-white px-4 text-[12px] font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:border-white/15 dark:bg-transparent" type="button" onClick={() => setDetailsOpen(true)}>{copy.settings}</button>
</header>
<div className="grid min-h-[655px] lg:grid-cols-[260px_minmax(360px,1fr)_260px]" id="relay-inbox">
<aside className="border-b border-neutral-200 bg-[#fafafa] p-3 lg:border-b-0 lg:border-r dark:border-white/10 dark:bg-[#181818]">
<div className="flex min-h-11 items-center justify-between gap-3 px-2"><h1 className="text-[13px] font-semibold">{copy.inbox}</h1><span className="font-mono text-[9px] text-neutral-500">{copy.conversations.length}</span></div>
<div className="mt-2 grid max-h-[238px] gap-1 overflow-auto lg:max-h-none">
{copy.conversations.map((conversation) => (
<button
aria-pressed={conversation.id === active.id}
className={`min-h-[84px] rounded-lg p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 ${conversation.id === active.id ? "bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950" : "hover:bg-neutral-100 dark:hover:bg-white/5"}`}
key={conversation.id}
onClick={() => { setActiveId(conversation.id); setSendState("idle"); }}
type="button"
>
<span className="flex items-center justify-between gap-3"><strong className="truncate text-[11px]">{conversation.name}</strong><span className="shrink-0 font-mono text-[8px] text-neutral-400">{conversation.time}</span></span>
<span className="mt-2 block truncate text-[10px] text-neutral-500 dark:text-neutral-400">{conversation.subject}</span>
<span className="mt-2 flex items-center gap-2"><i className={`size-1.5 rounded-full ${conversation.priority ? "bg-amber-500" : "bg-emerald-500"}`} /><small className="text-[9px] text-neutral-400">{conversation.channel}</small></span>
</button>
))}
</div>
</aside>
<section className="min-w-0 bg-white dark:bg-[#1d1d1b]" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
<motion.div className="grid min-h-[655px] grid-rows-[auto_1fr_auto]" key={active.id} initial={{ opacity: 0, x: reduced ? 0 : 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: reduced ? 0 : -10 }} transition={{ duration: reduced ? 0 : .22, ease }}>
<header className="flex min-h-[76px] items-center justify-between gap-4 border-b border-neutral-900/10 px-4 sm:px-5 dark:border-white/10">
<div className="min-w-0"><span className="font-mono text-[9px] text-neutral-500 dark:text-neutral-400">{active.ticket}</span><h2 className="mt-1 truncate text-[15px] font-semibold tracking-[-.02em]">{active.subject}</h2><InlineEdit value={active.subject} label={locale === "zh" ? "会话主题" : "Conversation subject"} className="mt-3 max-w-md" /></div>
<button className="min-h-11 shrink-0 rounded-lg bg-neutral-950 px-4 text-[11px] font-semibold text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:bg-neutral-50 dark:text-neutral-950" type="button">{copy.resolve}</button>
</header>
<div className="min-h-0 overflow-auto p-4 sm:p-5">
<div className="mx-auto grid max-w-xl gap-5">
{active.messages.map((message, index) => <motion.article className={`grid gap-2 ${message.agent ? "justify-items-end" : "justify-items-start"}`} initial={{ opacity: 0, y: reduced ? 0 : 7 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: reduced ? 0 : index * .05 }} key={`${active.id}-${index}`}><span className="text-[9px] text-neutral-400">{message.author} · {message.time}</span><p className={`m-0 max-w-[88%] rounded-[10px] px-4 py-3 text-[11px] leading-5 ${message.agent ? "rounded-br-md bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950" : "rounded-bl-md bg-neutral-100 text-neutral-700 dark:bg-white/7 dark:text-neutral-200"}`}>{message.body}</p></motion.article>)}
{sendState === "sent" ? <motion.article className="grid justify-items-end gap-2" initial={{ opacity: 0, y: reduced ? 0 : 8 }} animate={{ opacity: 1, y: 0 }}><span className="text-[9px] text-neutral-400">{copy.you} · {copy.now}</span><p className="m-0 max-w-[88%] rounded-[10px] rounded-br-md bg-neutral-950 px-4 py-3 text-[11px] leading-5 text-white dark:bg-neutral-50 dark:text-neutral-950">{copy.sentMessage}</p></motion.article> : null}
</div>
</div>
<div className="border-t border-neutral-900/10 p-3 sm:p-4 dark:border-white/10">
<div className="mx-auto max-w-xl rounded-[10px] border border-neutral-900/10 bg-neutral-50 p-2 shadow-[inset_0_1px_2px_rgba(28,25,23,.04)] focus-within:ring-2 focus-within:ring-blue-500 dark:border-white/10 dark:bg-black/10">
<label className="sr-only" htmlFor={`relay-reply-${active.id}`}>{copy.reply}</label>
<textarea className="min-h-20 w-full resize-none bg-transparent px-2 py-2 text-[16px] leading-5 outline-none placeholder:text-neutral-400 md:text-[11px]" id={`relay-reply-${active.id}`} onChange={(event) => { setReply(event.target.value); setSendState("idle"); }} onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); sendReply(); } }} placeholder={copy.placeholder} value={reply} />
<div className="flex items-center justify-between gap-3 px-1 pb-1"><span className="font-mono text-[8px] text-neutral-400">{sendState === "sending" ? copy.sending : sendState === "sent" ? copy.sent : copy.shortcut}</span><LoadingButton className="min-h-11 bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950" disabled={!reply.trim() || sendState === "sending"} onAction={sendReply} pendingLabel={copy.sending} successLabel={copy.sent}>{copy.send}</LoadingButton></div>
</div>
</div>
</motion.div>
</AnimatePresence>
</section>
<aside className="hidden border-l border-neutral-200 bg-[#fafafa] p-4 lg:block dark:border-white/10 dark:bg-[#181818]">
<div className="flex items-center gap-3"><span className={`grid size-10 place-items-center rounded-full text-[12px] font-bold ${active.avatarTone}`}>{active.initials}</span><div className="min-w-0"><strong className="block truncate text-[12px]">{active.name}</strong><span className="text-[9px] text-neutral-400">{active.company}</span></div></div>
<MetricTicker label={locale === "zh" ? "首响时间" : "First response"} value={42} format={(value) => `${value}m`} delta={-18} period={locale === "zh" ? "本周" : "this week"} className="mt-5" />
<dl className="mt-6 grid gap-4">
{active.details.map((detail) => <div className="grid gap-1 border-b border-neutral-900/[.07] pb-3 dark:border-white/[.07]" key={detail.label}><dt className="text-[9px] text-neutral-400">{detail.label}</dt><dd className="m-0 text-[11px] font-medium">{detail.value}</dd></div>)}
</dl>
<section className="mt-6 border-t border-neutral-200 pt-4 dark:border-white/10" aria-labelledby={`relay-context-${active.id}`}><h3 className="text-[10px] font-semibold" id={`relay-context-${active.id}`}>{copy.context}</h3><p className="mb-0 mt-3 text-[10px] leading-5 text-neutral-500 dark:text-neutral-300">{active.context}</p></section>
</aside>
</div>
<Drawer open={detailsOpen} onOpenChange={setDetailsOpen} title={copy.settings} description={active.company} side="right">
<div className="grid gap-3 text-[12px]">
{active.details.map((detail) => <div className="flex items-center justify-between gap-4 border-b border-neutral-200 pb-3 dark:border-white/10" key={detail.label}><span className="text-neutral-500 dark:text-neutral-400">{detail.label}</span><strong>{detail.value}</strong></div>)}
</div>
</Drawer>
</section>
);
}
type Conversation = {
id: string; name: string; initials: string; avatarTone: string; company: string; time: string; subject: string; ticket: string; channel: string; priority: boolean; context: string;
messages: Array<{ author: string; time: string; body: string; agent?: boolean }>;
details: Array<{ label: string; value: string }>;
};
const enConversations: Conversation[] = [
{ id: "conv-1", name: "Maya Chen", initials: "MC", avatarTone: "bg-[#d6b08e] text-[#5a321c]", company: "Morrow Labs", time: "4m", subject: "Workspace members cannot accept invites", ticket: "TICKET · 2841", channel: "Email", priority: true, context: "Morrow Labs is rolling out Atlas to 42 teammates this week. Their security review was approved yesterday.", messages: [{ author: "Maya", time: "09:14", body: "Three teammates see an expired-link message even though the invitations were sent this morning." }, { author: "You", time: "09:18", body: "I found the affected invitation batch. I’m checking the workspace domain policy before I refresh those links.", agent: true }, { author: "Maya", time: "09:21", body: "Thanks. Everyone is using our @morrowlabs.com address." }], details: [{ label: "Plan", value: "Scale" }, { label: "Workspace", value: "morrow-labs" }, { label: "Region", value: "Singapore" }, { label: "Owner", value: "You" }] },
{ id: "conv-2", name: "Leo Martins", initials: "LM", avatarTone: "bg-[#a8bfd3] text-[#24405a]", company: "Fable Studio", time: "12m", subject: "Need a copy of the July invoice", ticket: "TICKET · 2837", channel: "Chat", priority: false, context: "Fable Studio has been active for 18 months and recently added two contractor seats.", messages: [{ author: "Leo", time: "09:02", body: "Could you send the July invoice with our updated legal address?" }, { author: "You", time: "09:07", body: "I can regenerate it after confirming the billing profile. The new address is already saved.", agent: true }], details: [{ label: "Plan", value: "Team" }, { label: "Workspace", value: "fable-studio" }, { label: "Region", value: "Portugal" }, { label: "Owner", value: "You" }] },
{ id: "conv-3", name: "Nora Singh", initials: "NS", avatarTone: "bg-[#b6c9aa] text-[#304b27]", company: "Aster Works", time: "26m", subject: "Export stopped at 92 percent", ticket: "TICKET · 2832", channel: "Email", priority: true, context: "Aster Works exports weekly research archives. Their latest archive contains 1,840 records.", messages: [{ author: "Nora", time: "08:41", body: "Our weekly export has stayed at 92 percent for about fifteen minutes." }, { author: "You", time: "08:49", body: "The archive is still processing attachments. I’m watching the job and can restart only that stage if it stalls.", agent: true }], details: [{ label: "Plan", value: "Scale" }, { label: "Workspace", value: "aster-works" }, { label: "Region", value: "London" }, { label: "Owner", value: "You" }] },
];
function localizeConversations(): Conversation[] {
const labels = ["方案", "工作区", "区域", "负责人"];
return enConversations.map((conversation, index) => ({ ...conversation, time: ["4 分钟", "12 分钟", "26 分钟"][index], subject: ["工作区成员无法接受邀请", "需要一份七月发票", "导出停在 92%"][index], channel: conversation.channel === "Email" ? "邮件" : "在线聊天", context: ["Morrow Labs 本周正在向 42 位团队成员推广 Atlas,安全审查已于昨天通过。", "Fable Studio 已使用产品 18 个月,最近增加了两个外部协作者席位。", "Aster Works 每周导出研究档案,最新档案包含 1,840 条记录。"][index], messages: index === 0 ? [{ author: "Maya", time: "09:14", body: "三位同事看到链接已过期,但这些邀请都是今天上午发送的。" }, { author: "我", time: "09:18", body: "我已经找到受影响的邀请批次,正在检查工作区域名策略,然后会刷新链接。", agent: true }, { author: "Maya", time: "09:21", body: "谢谢,大家都使用 @morrowlabs.com 邮箱。" }] : index === 1 ? [{ author: "Leo", time: "09:02", body: "可以提供更新公司地址后的七月发票吗?" }, { author: "我", time: "09:07", body: "新的账单地址已经保存,我确认资料后会重新生成发票。", agent: true }] : [{ author: "Nora", time: "08:41", body: "本周导出已经停在 92% 大约十五分钟。" }, { author: "我", time: "08:49", body: "系统仍在处理附件。我正在观察任务,如果停滞会只重启这个阶段。", agent: true }], details: conversation.details.map((detail, detailIndex) => ({ label: labels[detailIndex], value: detail.value === "You" ? "我" : detail.value })) }));
}
const en = { search: "Search conversations", settings: "Inbox settings", inbox: "Priority inbox", resolve: "Resolve", reply: "Reply", placeholder: "Write a clear next step…", shortcut: "⌘ ENTER TO SEND", send: "Send reply", sending: "Sending…", sent: "Reply sent", sentMessage: "I refreshed the invitation batch and confirmed the new links are active.", you: "You", now: "Now", context: "Customer context", conversations: enConversations };
const zh = { search: "搜索会话", settings: "收件箱设置", inbox: "优先收件箱", resolve: "解决会话", reply: "回复", placeholder: "写下清楚的下一步…", shortcut: "⌘ ENTER 发送", send: "发送回复", sending: "发送中…", sent: "回复已发送", sentMessage: "我已刷新邀请批次,并确认新链接可以正常使用。", you: "我", now: "刚刚", context: "客户背景", conversations: localizeConversations() };