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