diff --git a/apps/web/src/app/accounts/page.tsx b/apps/web/src/app/accounts/page.tsx
index 53df973..d847734 100644
--- a/apps/web/src/app/accounts/page.tsx
+++ b/apps/web/src/app/accounts/page.tsx
@@ -1,22 +1,4 @@
-import Link from "next/link";
-import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { AccountStatusBadge } from "@/components/account-status-badge";
+import { AccountsListView } from "@/components/accounts-list-view";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries";
import { deleteAccountAction } from "@/actions/accounts";
@@ -25,130 +7,5 @@ export default async function AccountsPage() {
const op = await getSeededOperator();
const accounts = await listAccounts(op.id);
- return (
-
-
-
Accounts
-
-
-
- {accounts.length > 0 ? (
-
- {accounts.map((account) => (
-
-
-
-
-
-
- {account.label}
-
-
-
-
-
- {account.phoneNumber ? (
-
-
- {account.phoneNumber}
-
- ) : (
- Not paired yet
- )}
- {account.lastConnectedAt ? (
-
-
-
- Last connected{" "}
- {account.lastConnectedAt.toLocaleDateString("en-MY", {
- timeZone: "Asia/Kuala_Lumpur",
- year: "numeric",
- month: "short",
- day: "numeric",
- })}
-
-
- ) : null}
-
-
-
-
- {/* Dedicated Delete card — entire card is the dialog trigger. */}
-
-
- ))}
-
- ) : (
-
-
-
-
-
No accounts paired yet.
-
- Pair a WhatsApp account to start scheduling reminders.
-
-
-
-
-
- )}
-
- );
+ return ;
}
diff --git a/apps/web/src/components/accounts-list-view.test.tsx b/apps/web/src/components/accounts-list-view.test.tsx
new file mode 100644
index 0000000..c1aac06
--- /dev/null
+++ b/apps/web/src/components/accounts-list-view.test.tsx
@@ -0,0 +1,184 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+import {
+ AccountsListView,
+ type AccountsListAccount,
+} from "./accounts-list-view";
+
+// next/link in node tests can't access the Next router context — render a
+// plain anchor with the href so we can assert on it.
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record) => (
+
+ {children}
+
+ ),
+}));
+
+// Radix Dialog uses portals + client refs. For SSR markup tests we want
+// the trigger to be rendered inline (with `asChild`) and the content tree
+// to render too, so we can assert dialog text deterministically.
+vi.mock("./ui/dialog", () => ({
+ Dialog: ({ children }: { children: ReactNode }) => <>{children}>,
+ DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+ DialogContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogHeader: ({ children }: { children: ReactNode }) => {children}
,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children}
,
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+const mkAccount = (over: Partial = {}): AccountsListAccount => ({
+ id: "a-1",
+ label: "Personal",
+ status: "connected",
+ phoneNumber: "+60123456789",
+ lastConnectedAt: new Date("2026-05-01T10:00:00Z"),
+ ...over,
+});
+
+function render(html: string) {
+ // Count occurrences of a substring.
+ return {
+ html,
+ count(needle: string): number {
+ let n = 0;
+ let i = 0;
+ while (true) {
+ const j = html.indexOf(needle, i);
+ if (j === -1) return n;
+ n++;
+ i = j + needle.length;
+ }
+ },
+ has(re: RegExp): boolean {
+ return re.test(html);
+ },
+ };
+}
+
+const noopAction = () => {};
+
+describe("AccountsListView", () => {
+ describe("layout — accounts present", () => {
+ it("renders one cell per account, each with a main card and a delete card", () => {
+ const accounts = [
+ mkAccount({ id: "a-1", label: "Personal" }),
+ mkAccount({ id: "a-2", label: "Work" }),
+ mkAccount({ id: "a-3", label: "Support" }),
+ ];
+
+ const { count } = render(
+ renderToStaticMarkup(
+ ,
+ ),
+ );
+
+ expect(count('data-testid="account-cell"')).toBe(3);
+ expect(count('data-testid="account-card"')).toBe(3);
+ expect(count('data-testid="account-delete-card"')).toBe(3);
+ });
+
+ it("each main card links to /accounts/[id]", () => {
+ const { has } = render(
+ renderToStaticMarkup(
+ ,
+ ),
+ );
+ expect(has(/href="\/accounts\/abc-123"/)).toBe(true);
+ });
+
+ it("renders the account label in both the main card and the delete card", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // Main card title
+ expect(html).toContain(">MyBiz<");
+ // Delete card description references the account by name
+ expect(html).toContain("Remove MyBiz and its reminders");
+ // Dialog confirmation also uses the label
+ expect(html).toMatch(/MyBiz<\/strong>/);
+ });
+
+ it("delete card is a button with the destructive aria-label", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // Order of attributes in the rendered