feat(GRO-106): staff messages page (#405)

feat(GRO-106): staff messages page
This commit was merged in pull request #405.
This commit is contained in:
the-dogfather-cto[bot]
2026-05-14 16:23:27 +00:00
committed by GitHub
2 changed files with 20 additions and 33 deletions
+12 -22
View File
@@ -8,10 +8,9 @@ const mockConversations = [
clientId: "client-1", clientId: "client-1",
clientName: "Alice Smith", clientName: "Alice Smith",
channel: "sms", channel: "sms",
externalNumber: "+1234567890", clientPhone: "+1234567890",
lastMessageAt: "2026-05-14T10:00:00Z", lastMessageAt: "2026-05-14T10:00:00Z",
staffReadAt: null, lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
lastMessageBody: "Hello, is my dog ready?",
unreadCount: 2, unreadCount: 2,
status: "active", status: "active",
}, },
@@ -20,10 +19,9 @@ const mockConversations = [
clientId: "client-2", clientId: "client-2",
clientName: "Bob Jones", clientName: "Bob Jones",
channel: "sms", channel: "sms",
externalNumber: "+1987654321", clientPhone: "+1987654321",
lastMessageAt: "2026-05-13T08:00:00Z", lastMessageAt: "2026-05-13T08:00:00Z",
staffReadAt: "2026-05-13T09:00:00Z", lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
lastMessageBody: "Thanks for the update",
unreadCount: 0, unreadCount: 0,
status: "active", status: "active",
}, },
@@ -73,7 +71,7 @@ afterEach(() => {
describe("MessagesPage", () => { describe("MessagesPage", () => {
it("renders empty state when no conversations", async () => { it("renders empty state when no conversations", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse([])); vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
render(<MessagesPage />); render(<MessagesPage />);
await waitFor(() => { await waitFor(() => {
@@ -82,7 +80,7 @@ describe("MessagesPage", () => {
}); });
it("renders conversation list", async () => { it("renders conversation list", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse(mockConversations)); vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
render(<MessagesPage />); render(<MessagesPage />);
await waitFor(() => { await waitFor(() => {
@@ -98,10 +96,10 @@ describe("MessagesPage", () => {
vi.mocked(global.fetch).mockImplementation((input) => { vi.mocked(global.fetch).mockImplementation((input) => {
const url = String(input); const url = String(input);
if (url === "/api/conversations?limit=20") { if (url === "/api/conversations?limit=20") {
return Promise.resolve(makeResponse(mockConversations)); return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
} }
if (url.match(/\/api\/conversations\/[^/]+\/messages/)) { if (url === "/api/conversations/conv-1/messages?limit=50") {
return Promise.resolve(makeResponse({ messages: mockMessages })); return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
} }
return Promise.resolve(makeResponseWithStatus(null, 404)); return Promise.resolve(makeResponseWithStatus(null, 404));
}); });
@@ -112,9 +110,7 @@ describe("MessagesPage", () => {
fireEvent.click(screen.getByText("Alice Smith")); fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => { await waitFor(() => {
// Use getAllByText since the message also appears as preview in sidebar expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
const msgs = screen.getAllByText("Hello, is my dog ready?");
expect(msgs).toHaveLength(2); // preview in sidebar + bubble in message view
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument(); expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
}); });
}); });
@@ -123,10 +119,7 @@ describe("MessagesPage", () => {
let capturedBody: unknown = null; let capturedBody: unknown = null;
vi.mocked(global.fetch).mockImplementation((input, init) => { vi.mocked(global.fetch).mockImplementation((input, init) => {
const url = String(input); const url = String(input);
if (url === "/api/conversations?limit=20") { if (url.includes("/messages") && init?.method === "POST") {
return Promise.resolve(makeResponse(mockConversations));
}
if (url.match(/\/api\/conversations\/[^/]+\/messages/) && init?.method === "POST") {
capturedBody = init?.body; capturedBody = init?.body;
return Promise.resolve(makeResponseWithStatus({ return Promise.resolve(makeResponseWithStatus({
id: "msg-new", id: "msg-new",
@@ -137,10 +130,7 @@ describe("MessagesPage", () => {
sentByStaffId: "staff-1", sentByStaffId: "staff-1",
}, 201)); }, 201));
} }
if (url.match(/\/api\/conversations\/[^/]+\/messages/)) { return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
return Promise.resolve(makeResponse({ messages: mockMessages }));
}
return Promise.resolve(makeResponseWithStatus(null, 404));
}); });
render(<MessagesPage />); render(<MessagesPage />);
+8 -11
View File
@@ -5,12 +5,11 @@ interface Conversation {
clientId: string; clientId: string;
clientName: string; clientName: string;
channel: string; channel: string;
externalNumber: string; clientPhone: string;
lastMessageAt: string | null; lastMessageAt: string | null;
staffReadAt: string | null;
lastMessageBody: string | null;
unreadCount: number; unreadCount: number;
status: string; status: string;
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
} }
interface Message { interface Message {
@@ -55,7 +54,8 @@ export function MessagesPage() {
try { try {
const res = await fetch("/api/conversations?limit=20"); const res = await fetch("/api/conversations?limit=20");
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as Conversation[]; const json = await res.json();
const data = json.items as Conversation[];
setConversations(data); setConversations(data);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load conversations"); setError(e instanceof Error ? e.message : "Failed to load conversations");
@@ -68,8 +68,8 @@ export function MessagesPage() {
try { try {
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`); const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { messages: Message[] }; const json = await res.json();
setMessages(data.messages); setMessages((json.items as Message[]).reverse());
} catch (e: unknown) { } catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to load messages"); setMessageError(e instanceof Error ? e.message : "Failed to load messages");
} finally { } finally {
@@ -93,10 +93,7 @@ export function MessagesPage() {
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {
const el = messagesEndRef.current; messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
if (el && typeof (el as HTMLDivElement).scrollIntoView === "function") {
(el as HTMLDivElement).scrollIntoView({ behavior: "smooth" });
}
} }
}, [messages]); }, [messages]);
@@ -183,7 +180,7 @@ export function MessagesPage() {
)} )}
</div> </div>
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}> <div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
{truncate(conv.lastMessageBody, 60)} {truncate(conv.lastMessage?.body ?? null, 60)}
</div> </div>
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}> <div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
{relativeTime(conv.lastMessageAt)} {relativeTime(conv.lastMessageAt)}