diff --git a/apps/web/src/actions/accounts.ts b/apps/web/src/actions/accounts.ts index 813554a..67c9643 100644 --- a/apps/web/src/actions/accounts.ts +++ b/apps/web/src/actions/accounts.ts @@ -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 { + 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 diff --git a/apps/web/src/app/accounts/[id]/edit/label/page.tsx b/apps/web/src/app/accounts/[id]/edit/label/page.tsx new file mode 100644 index 0000000..51f3b2f --- /dev/null +++ b/apps/web/src/app/accounts/[id]/edit/label/page.tsx @@ -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 ( +
+ + +
+

Edit name

+

+ The label shown in the accounts list, detail header, and activity log. +

+
+ + + + + + +
+ ); +} diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx index 966a0f9..8885862 100644 --- a/apps/web/src/app/accounts/[id]/page.tsx +++ b/apps/web/src/app/accounts/[id]/page.tsx @@ -8,6 +8,7 @@ import { CalendarIcon, TagIcon, DatabaseIcon, + PencilIcon, PowerIcon, PowerOffIcon, ChevronRightIcon, @@ -74,6 +75,30 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
+ {/* Name — dedicated edit route mirrors the reminder edit-name + pattern. Tapping the row opens a focused editor; the + label is purely operator-facing. */} + + + +
+
+ +
+
+

Name

+

{account.label}

+
+
+ +
+
+ + {/* 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
Paired at
- {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, })}
diff --git a/apps/web/src/components/account-edit/edit-label-form.test.tsx b/apps/web/src/components/account-edit/edit-label-form.test.tsx new file mode 100644 index 0000000..8eb01b8 --- /dev/null +++ b/apps/web/src/components/account-edit/edit-label-form.test.tsx @@ -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( + , + ); + expect(html).toMatch(/]*value="Personal"/); + }); + + it("renders a Save button", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Save<\/button>/); + }); + + it("marks the input as required so empty submits don't reach the server", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/]*required[^>]*aria-required="true"/); + }); + + it("caps input length to 60 chars (matches the server schema)", () => { + const html = renderToStaticMarkup( + , + ); + 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", + }); + }); +}); diff --git a/apps/web/src/components/account-edit/edit-label-form.tsx b/apps/web/src/components/account-edit/edit-label-form.tsx new file mode 100644 index 0000000..403073b --- /dev/null +++ b/apps/web/src/components/account-edit/edit-label-form.tsx @@ -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(initialLabel); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+ + { + setLabel(e.target.value); + setError(null); + }} + placeholder="e.g. Personal, Sales line, Backup phone" + /> +

+ Shown in the accounts list, page headers, and activity log. WhatsApp + doesn't see this name. +

+
+ + {error && ( +
+ + {error} +
+ )} + +
+ +
+
+ ); +} diff --git a/apps/web/src/components/account-swipeable-row.test.tsx b/apps/web/src/components/account-swipeable-row.test.tsx new file mode 100644 index 0000000..b710480 --- /dev/null +++ b/apps/web/src/components/account-swipeable-row.test.tsx @@ -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( + +
Personal
+
, + ); + 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( + +
+ , + ); + 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( + +
+ , + ); + 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( + +
+ , + ); + 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( + +
+ , + ); + // 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( + +
+ , + ); + const inputs = html.match(/]*name="accountId"[^>]*value="a-1"/g) ?? []; + // Pair (left shelf) + Delete (right shelf) = 2 forms. + expect(inputs.length).toBe(2); + }); +}); diff --git a/apps/web/src/components/account-swipeable-row.tsx b/apps/web/src/components/account-swipeable-row.tsx new file mode 100644 index 0000000..308b80a --- /dev/null +++ b/apps/web/src/components/account-swipeable-row.tsx @@ -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 ( + + ) : ( + + ) + } + rightActions={ +
+ + +
+ } + > + {children} +
+ ); +} + +function PairShelfButton({ accountId }: { accountId: string }) { + return ( +
+ + +
+ ); +} + +function UnpairShelfButton({ accountId }: { accountId: string }) { + return ( +
+ + +
+ ); +} + +function GroupsShelfButton({ accountId }: { accountId: string }) { + return ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + + + Groups + + ); +} + +function DeleteShelfButton({ accountId }: { accountId: string }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/accounts-list-view.tsx b/apps/web/src/components/accounts-list-view.tsx index 41c2081..12228c4 100644 --- a/apps/web/src/components/accounts-list-view.tsx +++ b/apps/web/src/components/accounts-list-view.tsx @@ -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 ? ( -
- {accounts.map((account) => ( - - + {/* Mobile: swipeable single-column list. Drag-right reveals + Pair / Unpair, drag-left reveals Groups + Delete. */} +
+

+ Swipe right to {accounts.some((a) => a.status === "connected") ? "pair / unpair" : "pair"}, + left to manage groups or delete. +

+ {accounts.map((account) => ( + - -
- {account.label} - -
-
- - {account.phoneNumber ? ( -
- - {account.phoneNumber} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + +
+

{account.label}

+ {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

+ Not paired yet +

+ )} +
+ +
+
+ + + ))} +
+ + {/* Desktop: grid of clickable cards (no swipe — click into the + detail page for the same actions). */} +
+ {accounts.map((account) => ( + + + +
+ {account.label} +
- ) : ( -

Not paired yet

- )} - {account.lastConnectedAt ? ( -
- - - 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, - })} - -
- ) : null} - -
- - ))} -
+ + + {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

Not paired yet

+ )} + {account.lastConnectedAt ? ( +
+ + + 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, + })} + +
+ ) : null} +
+ + + ))} +
+ ) : (
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) => ( + + {children} + + ), +})); + +// 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 `&`. Match either form so the + // test doesn't break if React's escaping behaviour shifts. + const HREF_ON = /href="\/reminders\/new\?step=2(?:&|&)accountId=acc-on"/; + const HREF_OFF = /href="\/reminders\/new\?step=2(?:&|&)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 . + expect(html).toMatch( + new RegExp(`]*${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(/]*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"); + }); +}); diff --git a/apps/web/src/components/reminder-wizard/step-account.tsx b/apps/web/src/components/reminder-wizard/step-account.tsx index f0327e6..27c1534 100644 --- a/apps/web/src/components/reminder-wizard/step-account.tsx +++ b/apps/web/src/components/reminder-wizard/step-account.tsx @@ -41,6 +41,69 @@ export async function StepAccount() {
{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 = ( + + +
+ +
+
+

{account.label}

+

+ {isConnected + ? (account.phoneNumber ?? "No phone number") + : `Not connected · pair to use`} +

+
+
+ {isConnected ? ( + + ) : ( + + )} +
+
+
+ ); + + // 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 + //
with aria-disabled instead of a Link, so it doesn't + // navigate AND assistive tech announces the disabled state. + if (!isConnected) { + return ( +
+ {body} +
+ ); + } + return ( - - -
- -
-
-

{account.label}

-

- {account.phoneNumber ?? "No phone number"} -

-
-
- {isConnected ? ( - - ) : ( - - )} -
-
-
+ {body} ); })} diff --git a/apps/web/src/components/swipeable-row.tsx b/apps/web/src/components/swipeable-row.tsx index e2018fe..04d3723 100644 --- a/apps/web/src/components/swipeable-row.tsx +++ b/apps/web/src/components/swipeable-row.tsx @@ -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({
{leftActions}
@@ -122,7 +138,7 @@ export function SwipeableRow({
= 0} className="absolute inset-y-0 right-0 flex items-stretch" - style={{ width: SHELF_WIDTH }} + style={{ width: rightWidth }} > {rightActions}
@@ -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; }