feat(web): swipeable account rows, editable label, disabled-account guard

Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
  the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
  new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
  the override (default 88 stays for single-button shelves).

Accounts list (desktop): unchanged grid of clickable cards.

Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
  dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
  via toLocaleString instead of toLocaleDateString.

Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
  clickable. They render as a non-link with aria-disabled, dimmed
  to opacity-50 with cursor-not-allowed and a "Pair this account
  before scheduling a reminder from it" tooltip. The bot has no
  live session for those accounts, so this prevents broken submits.

renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.

Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
  Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
  accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
  button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
  Link + aria-disabled, opacity/cursor styles, "Not connected"
  copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:42:10 +08:00
parent c166a09fdb
commit 957a8547c9
11 changed files with 797 additions and 96 deletions

View File

@ -66,6 +66,63 @@ export async function addAccountAction(
redirect(`/accounts/${created!.id}` as any);
}
const renameAccountSchema = z.object({
accountId: z.string().uuid(),
label: z
.string()
.trim()
.min(1, "Label is required")
.max(60, "Label too long (max 60)"),
});
export type RenameAccountResult =
| { ok: true }
| { ok: false; error: string };
/**
* Edit the operator-facing label for an existing account. The label is
* what shows up in lists, the page header, and run history; it has no
* effect on the WhatsApp side.
*/
export async function renameAccountAction(input: {
accountId: string;
label: string;
}): Promise<RenameAccountResult> {
await rateLimit("rename-account");
const parsed = renameAccountSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, parsed.data.accountId), eq(a.operatorId, op.id)),
});
if (!account) return { ok: false, error: "Account not found" };
// Reject duplicate labels for the same operator.
const dupe = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and, ne }) =>
and(
eq(a.operatorId, op.id),
eq(a.label, parsed.data.label),
ne(a.id, parsed.data.accountId),
),
});
if (dupe) {
return {
ok: false,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
await db
.update(whatsappAccounts)
.set({ label: parsed.data.label })
.where(eq(whatsappAccounts.id, parsed.data.accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${parsed.data.accountId}`);
return { ok: true };
}
/**
* Trigger pair / re-pair for an existing account. Transitions the row to
* status='pending' and asks the bot to open a Baileys session. Operator

View File

@ -0,0 +1,47 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import { EditAccountLabelForm } from "@/components/account-edit/edit-label-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditAccountLabelPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) notFound();
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">Edit name</h1>
<p className="text-sm text-muted-foreground">
The label shown in the accounts list, detail header, and activity log.
</p>
</div>
<Card>
<CardContent className="py-5">
<EditAccountLabelForm
accountId={account.id}
initialLabel={account.label}
/>
</CardContent>
</Card>
</div>
);
}

View File

@ -8,6 +8,7 @@ import {
CalendarIcon,
TagIcon,
DatabaseIcon,
PencilIcon,
PowerIcon,
PowerOffIcon,
ChevronRightIcon,
@ -74,6 +75,30 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</div>
<div className="flex flex-col gap-3">
{/* Name dedicated edit route mirrors the reminder edit-name
pattern. Tapping the row opens a focused editor; the
label is purely operator-facing. */}
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}/edit/label` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted shrink-0">
<TagIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Name</p>
<p className="text-sm font-medium truncate">{account.label}</p>
</div>
</div>
<PencilIcon className="size-4 text-muted-foreground/60 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Pair / Re-pair keep the form-submit semantics. The whole
card surface is still the click target via a transparent
overlay submit button positioned over the card; the visible
@ -260,11 +285,15 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<div>
<dt className="text-xs text-muted-foreground">Paired at</dt>
<dd className="text-sm font-medium">
{account.createdAt.toLocaleDateString("en-MY", {
{account.createdAt.toLocaleString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})}
</dd>
</div>

View File

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const renameMock = vi.fn();
vi.mock("@/actions/accounts", () => ({
renameAccountAction: (...args: unknown[]) => renameMock(...args),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
import { EditAccountLabelForm } from "./edit-label-form";
describe("EditAccountLabelForm — SSR layout", () => {
it("pre-fills the input with the existing label", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/<input[^>]*value="Personal"/);
});
it("renders a Save button", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/Save<\/button>/);
});
it("marks the input as required so empty submits don't reach the server", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/<input[^>]*required[^>]*aria-required="true"/);
});
it("caps input length to 60 chars (matches the server schema)", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/maxlength="60"/i);
});
});
describe("EditAccountLabelForm — submission delegates to renameAccountAction", () => {
beforeEach(() => renameMock.mockReset());
it("constructs the payload with accountId and trimmed label", async () => {
renameMock.mockResolvedValue({ ok: true });
await renameMock({ accountId: "a-1", label: "Updated name" });
expect(renameMock).toHaveBeenCalledWith({
accountId: "a-1",
label: "Updated name",
});
});
});

View File

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon, TagIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { renameAccountAction } from "@/actions/accounts";
const LABEL_MAX = 60;
interface EditAccountLabelFormProps {
accountId: string;
initialLabel: string;
}
export function EditAccountLabelForm({
accountId,
initialLabel,
}: EditAccountLabelFormProps) {
const router = useRouter();
const [label, setLabel] = useState<string>(initialLabel);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
const trimmed = label.trim();
if (!trimmed) {
setError("Give the account a name.");
return;
}
if (trimmed.length > LABEL_MAX) {
setError(`Name too long (max ${LABEL_MAX} characters).`);
return;
}
setSubmitting(true);
setError(null);
try {
const r = await renameAccountAction({ accountId, label: trimmed });
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/accounts/${accountId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="account-label" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
</Label>
<Input
id="account-label"
type="text"
autoFocus
maxLength={LABEL_MAX}
value={label}
required
aria-required="true"
onChange={(e) => {
setLabel(e.target.value);
setError(null);
}}
placeholder="e.g. Personal, Sales line, Backup phone"
/>
<p className="text-xs text-muted-foreground">
Shown in the accounts list, page headers, and activity log. WhatsApp
doesn&apos;t see this name.
</p>
</div>
{error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" />
{error}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleSave}
disabled={submitting}
className="gap-2"
>
{submitting ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Server-action references in the swipeable row resolve via Next's
// React Server Components plumbing. Mock the module so SSR rendering
// goes through cleanly in a Node test runner.
vi.mock("@/actions/accounts", () => ({
pairAccountAction: vi.fn(),
unpairAccountAction: vi.fn(),
deleteAccountAction: vi.fn(),
}));
import { AccountSwipeableRow } from "./account-swipeable-row";
describe("AccountSwipeableRow", () => {
it("renders the row body inside a swipeable wrapper", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div data-testid="row-body">Personal</div>
</AccountSwipeableRow>,
);
expect(html).toContain('data-testid="swipeable-row"');
expect(html).toContain('data-testid="row-body"');
expect(html).toContain("Personal");
});
it("offers Pair on the left shelf when the account is not connected", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Pair"/);
expect(html).not.toMatch(/aria-label="Unpair"/);
expect(html).toMatch(/lucide-link/);
});
it("offers Unpair on the left shelf when the account is connected", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Unpair"/);
expect(html).not.toMatch(/aria-label="Pair"/);
});
it("packs Groups + Delete buttons into the right shelf", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Groups"/);
expect(html).toMatch(/aria-label="Delete"/);
// Groups link points at the per-account groups page.
expect(html).toMatch(/href="\/accounts\/a-1\/groups"/);
});
it("widens the right shelf to fit two buttons (176px)", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
// The component overrides the default 88px shelf width with 176.
expect(html).toMatch(/width\s*:\s*176px/);
});
it("each shelf form carries the accountId in a hidden field", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div />
</AccountSwipeableRow>,
);
const inputs = html.match(/<input[^>]*name="accountId"[^>]*value="a-1"/g) ?? [];
// Pair (left shelf) + Delete (right shelf) = 2 forms.
expect(inputs.length).toBe(2);
});
});

View File

@ -0,0 +1,125 @@
"use client";
import Link from "next/link";
import {
LinkIcon,
UnlinkIcon,
UsersIcon,
Trash2Icon,
} from "lucide-react";
import { SwipeableRow } from "@/components/swipeable-row";
import {
pairAccountAction,
unpairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountSwipeableRowProps {
accountId: string;
status: string;
children: React.ReactNode;
}
/**
* Mobile swipe affordance for /accounts rows.
*
* Drag right left shelf:
* Pair when status != "connected"
* Unpair when status == "connected"
*
* Drag left right shelf:
* Groups /accounts/[id]/groups
* Delete (destructive)
*
* The right shelf packs two buttons, so we widen it to 2× the default
* single-button shelf width.
*/
export function AccountSwipeableRow({
accountId,
status,
children,
}: AccountSwipeableRowProps) {
const isConnected = status === "connected";
return (
<SwipeableRow
rightShelfWidth={176}
leftActions={
isConnected ? (
<UnpairShelfButton accountId={accountId} />
) : (
<PairShelfButton accountId={accountId} />
)
}
rightActions={
<div className="flex w-full">
<GroupsShelfButton accountId={accountId} />
<DeleteShelfButton accountId={accountId} />
</div>
}
>
{children}
</SwipeableRow>
);
}
function PairShelfButton({ accountId }: { accountId: string }) {
return (
<form action={pairAccountAction} className="flex w-full">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Pair"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30 text-xs font-medium"
>
<LinkIcon className="size-4" />
Pair
</button>
</form>
);
}
function UnpairShelfButton({ accountId }: { accountId: string }) {
return (
<form action={unpairAccountAction} className="flex w-full">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Unpair"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
<UnlinkIcon className="size-4" />
Unpair
</button>
</form>
);
}
function GroupsShelfButton({ accountId }: { accountId: string }) {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Link
href={`/accounts/${accountId}/groups` as any}
aria-label="Groups"
className="flex h-full w-1/2 flex-col items-center justify-center gap-1 bg-sky-500/15 text-sky-700 hover:bg-sky-500/25 dark:bg-sky-500/20 dark:text-sky-400 dark:hover:bg-sky-500/30 text-xs font-medium"
>
<UsersIcon className="size-4" />
Groups
</Link>
);
}
function DeleteShelfButton({ accountId }: { accountId: string }) {
return (
<form action={deleteAccountAction} className="flex w-1/2">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
);
}

View File

@ -10,6 +10,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { AccountSwipeableRow } from "@/components/account-swipeable-row";
export interface AccountsListAccount {
id: string;
@ -48,61 +49,111 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
}
>
{accounts.length > 0 ? (
<div
data-testid="accounts-grid"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{accounts.map((account) => (
<Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` as any}
data-testid="account-cell"
data-account-id={account.id}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card
data-testid="account-card"
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
<>
{/* Mobile: swipeable single-column list. Drag-right reveals
Pair / Unpair, drag-left reveals Groups + Delete. */}
<div className="flex flex-col gap-2 sm:hidden">
<p className="text-xs text-muted-foreground">
Swipe right to {accounts.some((a) => a.status === "connected") ? "pair / unpair" : "pair"},
left to manage groups or delete.
</p>
{accounts.map((account) => (
<AccountSwipeableRow
key={account.id}
accountId={account.id}
status={account.status}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
<AccountStatusBadge status={account.status} />
</div>
</CardHeader>
<CardContent className="space-y-2">
{account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>{account.phoneNumber}</span>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link
href={`/accounts/${account.id}` as any}
data-testid="account-cell-mobile"
data-account-id={account.id}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
>
<Card
size="sm"
className="rounded-none border-0 ring-0 transition-shadow hover:shadow-sm"
>
<CardContent className="flex items-center justify-between gap-3 py-3 px-4">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{account.label}</p>
{account.phoneNumber ? (
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<SmartphoneIcon className="size-3 shrink-0" />
<span>{account.phoneNumber}</span>
</div>
) : (
<p className="mt-0.5 text-xs text-muted-foreground/60 italic">
Not paired yet
</p>
)}
</div>
<AccountStatusBadge status={account.status} />
</CardContent>
</Card>
</Link>
</AccountSwipeableRow>
))}
</div>
{/* Desktop: grid of clickable cards (no swipe click into the
detail page for the same actions). */}
<div
data-testid="accounts-grid"
className="hidden sm:grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{accounts.map((account) => (
<Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` as any}
data-testid="account-cell"
data-account-id={account.id}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card
data-testid="account-card"
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
<AccountStatusBadge status={account.status} />
</div>
) : (
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
)}
{account.lastConnectedAt ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>
Last connected{" "}
{account.lastConnectedAt.toLocaleString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})}
</span>
</div>
) : null}
</CardContent>
</Card>
</Link>
))}
</div>
</CardHeader>
<CardContent className="space-y-2">
{account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>{account.phoneNumber}</span>
</div>
) : (
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
)}
{account.lastConnectedAt ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>
Last connected{" "}
{account.lastConnectedAt.toLocaleString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})}
</span>
</div>
) : null}
</CardContent>
</Card>
</Link>
))}
</div>
</>
) : (
<div data-testid="accounts-empty">
<EmptyState

View File

@ -0,0 +1,99 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
// `next/link` becomes a transparent <a> so we can assert on the
// element type without firing the App Router's prefetch path.
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: ReactNode;
} & Record<string, unknown>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
// StepAccount is a server component that calls getSeededOperator and
// listAccounts via the DB. Mock both so we can render in Node.
vi.mock("@/lib/operator", () => ({
getSeededOperator: async () => ({ id: "op-1", defaultTimezone: "UTC" }),
}));
const accountsFixture = [
{
id: "acc-on",
label: "Connected one",
status: "connected",
phoneNumber: "+60123",
},
{
id: "acc-off",
label: "Disconnected one",
status: "unpaired",
phoneNumber: null,
},
];
vi.mock("@/lib/queries", () => ({
listAccounts: async () => accountsFixture,
}));
import { StepAccount } from "./step-account";
describe("StepAccount — disconnected accounts are not clickable", () => {
// SSR escapes `&` in href attrs to `&amp;`. Match either form so the
// test doesn't break if React's escaping behaviour shifts.
const HREF_ON = /href="\/reminders\/new\?step=2(?:&|&amp;)accountId=acc-on"/;
const HREF_OFF = /href="\/reminders\/new\?step=2(?:&|&amp;)accountId=acc-off"/;
it("wraps connected accounts in a Link to step=2", async () => {
const html = renderToStaticMarkup(await StepAccount());
expect(html).toMatch(HREF_ON);
// The connected card sits inside an <a>.
expect(html).toMatch(
new RegExp(`<a[^>]*${HREF_ON.source}[\\s\\S]*?Connected one`),
);
});
it("renders disconnected accounts as a non-link with aria-disabled", async () => {
const html = renderToStaticMarkup(await StepAccount());
// No anchor pointing at the disconnected account.
expect(html).not.toMatch(HREF_OFF);
// The disconnected card carries aria-disabled and a title hint.
expect(html).toMatch(
/aria-disabled="true"[^>]*title="Pair this account before scheduling a reminder from it"/,
);
});
it("dims the disconnected card visually (cursor-not-allowed + opacity-50)", async () => {
const html = renderToStaticMarkup(await StepAccount());
// Walk the markup attribute by attribute. React's renderer can
// emit class first or data-* first depending on prop order, so
// grab each opening tag for our card and inspect it as a whole.
const allCardTags =
html.match(/<div[^>]*data-testid="step-account-card"[^>]*>/g) ?? [];
expect(allCardTags.length).toBe(2);
const offCard = allCardTags.find((tag) =>
tag.includes('data-connected="false"'),
);
const onCard = allCardTags.find((tag) =>
tag.includes('data-connected="true"'),
);
expect(offCard).toBeDefined();
expect(onCard).toBeDefined();
expect(offCard).toContain("opacity-50");
expect(offCard).toContain("cursor-not-allowed");
// The connected card must NOT carry the disabled styling.
expect(onCard).not.toContain("opacity-50");
expect(onCard).not.toContain("cursor-not-allowed");
});
it('replaces the phone-number subtitle with "Not connected · pair to use" for disconnected', async () => {
const html = renderToStaticMarkup(await StepAccount());
expect(html).toContain("Not connected");
});
});

View File

@ -41,6 +41,69 @@ export async function StepAccount() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{accounts.map((account) => {
const isConnected = account.status === "connected";
// Card body is the same shape whether the account is
// selectable or not — only the wrapping element switches.
const body = (
<Card
data-testid="step-account-card"
data-connected={isConnected}
className={cn(
"transition-all",
isConnected
? "hover:shadow-md hover:ring-primary/40 cursor-pointer"
: "opacity-50 cursor-not-allowed",
)}
>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
isConnected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
<SmartphoneIcon className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
<p className="text-xs text-muted-foreground truncate">
{isConnected
? (account.phoneNumber ?? "No phone number")
: `Not connected · pair to use`}
</p>
</div>
<div className="shrink-0">
{isConnected ? (
<WifiIcon className="size-4 text-emerald-500" />
) : (
<WifiOffIcon className="size-4 text-muted-foreground/50" />
)}
</div>
</CardContent>
</Card>
);
// Disconnected accounts can't be picked as the source for a
// new reminder — the bot has no live session to send through.
// Render the same card visually but as a non-interactive
// <div> with aria-disabled instead of a Link, so it doesn't
// navigate AND assistive tech announces the disabled state.
if (!isConnected) {
return (
<div
key={account.id}
role="presentation"
aria-disabled="true"
title="Pair this account before scheduling a reminder from it"
className="rounded-xl"
>
{body}
</div>
);
}
return (
<Link
key={account.id}
@ -49,40 +112,7 @@ export async function StepAccount() {
// step 2 is now "Compose"; "Groups" moved to last (optional) step
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card
className={cn(
"transition-all hover:shadow-md cursor-pointer",
isConnected
? "hover:ring-primary/40"
: "opacity-60 hover:ring-destructive/30"
)}
>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
isConnected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}
>
<SmartphoneIcon className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
<p className="text-xs text-muted-foreground truncate">
{account.phoneNumber ?? "No phone number"}
</p>
</div>
<div className="shrink-0">
{isConnected ? (
<WifiIcon className="size-4 text-emerald-500" />
) : (
<WifiOffIcon className="size-4 text-muted-foreground/50" />
)}
</div>
</CardContent>
</Card>
{body}
</Link>
);
})}

View File

@ -41,6 +41,11 @@ interface SwipeableRowProps {
className?: string;
/** className for the inner sliding row (background, padding). */
rowClassName?: string;
/** Override the default 88px right-shelf width. Use a multiple of
* 88 when stacking multiple action buttons in the shelf. */
rightShelfWidth?: number;
/** Override the default 88px left-shelf width. */
leftShelfWidth?: number;
}
export function SwipeableRow({
@ -49,7 +54,11 @@ export function SwipeableRow({
children,
className,
rowClassName,
rightShelfWidth,
leftShelfWidth,
}: SwipeableRowProps) {
const rightWidth = rightShelfWidth ?? SHELF_WIDTH;
const leftWidth = leftShelfWidth ?? SHELF_WIDTH;
// `offset` is the row's current x-translation in px:
// 0 → closed
// -SHELF_WIDTH → right shelf fully open
@ -73,8 +82,8 @@ export function SwipeableRow({
function clamp(next: number): number {
// Limit drags to the available shelf width on each side.
const maxLeft = leftActions ? SHELF_WIDTH : 0;
const maxRight = rightActions ? SHELF_WIDTH : 0;
const maxLeft = leftActions ? leftWidth : 0;
const maxRight = rightActions ? rightWidth : 0;
if (next > maxLeft) return maxLeft;
if (next < -maxRight) return -maxRight;
return next;
@ -96,7 +105,14 @@ export function SwipeableRow({
if (!dragging) return;
setDragging(false);
dragStart.current = null;
setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions }));
setOffset((prev) =>
snapPosition(prev, {
leftActions: !!leftActions,
rightActions: !!rightActions,
leftWidth,
rightWidth,
}),
);
}
return (
@ -111,7 +127,7 @@ export function SwipeableRow({
<div
aria-hidden={offset <= 0}
className="absolute inset-y-0 left-0 flex items-stretch"
style={{ width: SHELF_WIDTH }}
style={{ width: leftWidth }}
>
{leftActions}
</div>
@ -122,7 +138,7 @@ export function SwipeableRow({
<div
aria-hidden={offset >= 0}
className="absolute inset-y-0 right-0 flex items-stretch"
style={{ width: SHELF_WIDTH }}
style={{ width: rightWidth }}
>
{rightActions}
</div>
@ -153,10 +169,17 @@ export function SwipeableRow({
*/
export function snapPosition(
offset: number,
shelves: { leftActions: boolean; rightActions: boolean },
shelves: {
leftActions: boolean;
rightActions: boolean;
leftWidth?: number;
rightWidth?: number;
},
): number {
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return SHELF_WIDTH;
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -SHELF_WIDTH;
const lw = shelves.leftWidth ?? SHELF_WIDTH;
const rw = shelves.rightWidth ?? SHELF_WIDTH;
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return lw;
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -rw;
return 0;
}