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:
parent
c166a09fdb
commit
957a8547c9
@ -66,6 +66,63 @@ export async function addAccountAction(
|
|||||||
redirect(`/accounts/${created!.id}` as any);
|
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
|
* Trigger pair / re-pair for an existing account. Transitions the row to
|
||||||
* status='pending' and asks the bot to open a Baileys session. Operator
|
* status='pending' and asks the bot to open a Baileys session. Operator
|
||||||
|
|||||||
47
apps/web/src/app/accounts/[id]/edit/label/page.tsx
Normal file
47
apps/web/src/app/accounts/[id]/edit/label/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
|
PencilIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
PowerOffIcon,
|
PowerOffIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
@ -74,6 +75,30 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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
|
{/* Pair / Re-pair — keep the form-submit semantics. The whole
|
||||||
card surface is still the click target via a transparent
|
card surface is still the click target via a transparent
|
||||||
overlay submit button positioned over the card; the visible
|
overlay submit button positioned over the card; the visible
|
||||||
@ -260,11 +285,15 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Paired at</dt>
|
<dt className="text-xs text-muted-foreground">Paired at</dt>
|
||||||
<dd className="text-sm font-medium">
|
<dd className="text-sm font-medium">
|
||||||
{account.createdAt.toLocaleDateString("en-MY", {
|
{account.createdAt.toLocaleString("en-MY", {
|
||||||
timeZone: "Asia/Kuala_Lumpur",
|
timeZone: "Asia/Kuala_Lumpur",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: true,
|
||||||
})}
|
})}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
105
apps/web/src/components/account-edit/edit-label-form.tsx
Normal file
105
apps/web/src/components/account-edit/edit-label-form.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/web/src/components/account-swipeable-row.test.tsx
Normal file
80
apps/web/src/components/account-swipeable-row.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
apps/web/src/components/account-swipeable-row.tsx
Normal file
125
apps/web/src/components/account-swipeable-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
|
import { AccountSwipeableRow } from "@/components/account-swipeable-row";
|
||||||
|
|
||||||
export interface AccountsListAccount {
|
export interface AccountsListAccount {
|
||||||
id: string;
|
id: string;
|
||||||
@ -48,9 +49,58 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* 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}
|
||||||
|
>
|
||||||
|
{/* 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
|
<div
|
||||||
data-testid="accounts-grid"
|
data-testid="accounts-grid"
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
className="hidden sm:grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<Link
|
<Link
|
||||||
@ -103,6 +153,7 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div data-testid="accounts-empty">
|
<div data-testid="accounts-empty">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@ -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 `&`. 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 <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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -41,20 +41,18 @@ export async function StepAccount() {
|
|||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{accounts.map((account) => {
|
{accounts.map((account) => {
|
||||||
const isConnected = account.status === "connected";
|
const isConnected = account.status === "connected";
|
||||||
return (
|
|
||||||
<Link
|
// Card body is the same shape whether the account is
|
||||||
key={account.id}
|
// selectable or not — only the wrapping element switches.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const body = (
|
||||||
href={`/reminders/new?step=2&accountId=${account.id}` as any}
|
|
||||||
// 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
|
<Card
|
||||||
|
data-testid="step-account-card"
|
||||||
|
data-connected={isConnected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all hover:shadow-md cursor-pointer",
|
"transition-all",
|
||||||
isConnected
|
isConnected
|
||||||
? "hover:ring-primary/40"
|
? "hover:shadow-md hover:ring-primary/40 cursor-pointer"
|
||||||
: "opacity-60 hover:ring-destructive/30"
|
: "opacity-50 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
@ -63,7 +61,7 @@ export async function StepAccount() {
|
|||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||||
isConnected
|
isConnected
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SmartphoneIcon className="size-4.5" />
|
<SmartphoneIcon className="size-4.5" />
|
||||||
@ -71,7 +69,9 @@ export async function StepAccount() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
|
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{account.phoneNumber ?? "No phone number"}
|
{isConnected
|
||||||
|
? (account.phoneNumber ?? "No phone number")
|
||||||
|
: `Not connected · pair to use`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
@ -83,6 +83,36 @@ export async function StepAccount() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/reminders/new?step=2&accountId=${account.id}` as any}
|
||||||
|
// 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"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -41,6 +41,11 @@ interface SwipeableRowProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** className for the inner sliding row (background, padding). */
|
/** className for the inner sliding row (background, padding). */
|
||||||
rowClassName?: string;
|
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({
|
export function SwipeableRow({
|
||||||
@ -49,7 +54,11 @@ export function SwipeableRow({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
|
rightShelfWidth,
|
||||||
|
leftShelfWidth,
|
||||||
}: SwipeableRowProps) {
|
}: SwipeableRowProps) {
|
||||||
|
const rightWidth = rightShelfWidth ?? SHELF_WIDTH;
|
||||||
|
const leftWidth = leftShelfWidth ?? SHELF_WIDTH;
|
||||||
// `offset` is the row's current x-translation in px:
|
// `offset` is the row's current x-translation in px:
|
||||||
// 0 → closed
|
// 0 → closed
|
||||||
// -SHELF_WIDTH → right shelf fully open
|
// -SHELF_WIDTH → right shelf fully open
|
||||||
@ -73,8 +82,8 @@ export function SwipeableRow({
|
|||||||
|
|
||||||
function clamp(next: number): number {
|
function clamp(next: number): number {
|
||||||
// Limit drags to the available shelf width on each side.
|
// Limit drags to the available shelf width on each side.
|
||||||
const maxLeft = leftActions ? SHELF_WIDTH : 0;
|
const maxLeft = leftActions ? leftWidth : 0;
|
||||||
const maxRight = rightActions ? SHELF_WIDTH : 0;
|
const maxRight = rightActions ? rightWidth : 0;
|
||||||
if (next > maxLeft) return maxLeft;
|
if (next > maxLeft) return maxLeft;
|
||||||
if (next < -maxRight) return -maxRight;
|
if (next < -maxRight) return -maxRight;
|
||||||
return next;
|
return next;
|
||||||
@ -96,7 +105,14 @@ export function SwipeableRow({
|
|||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
dragStart.current = null;
|
dragStart.current = null;
|
||||||
setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions }));
|
setOffset((prev) =>
|
||||||
|
snapPosition(prev, {
|
||||||
|
leftActions: !!leftActions,
|
||||||
|
rightActions: !!rightActions,
|
||||||
|
leftWidth,
|
||||||
|
rightWidth,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -111,7 +127,7 @@ export function SwipeableRow({
|
|||||||
<div
|
<div
|
||||||
aria-hidden={offset <= 0}
|
aria-hidden={offset <= 0}
|
||||||
className="absolute inset-y-0 left-0 flex items-stretch"
|
className="absolute inset-y-0 left-0 flex items-stretch"
|
||||||
style={{ width: SHELF_WIDTH }}
|
style={{ width: leftWidth }}
|
||||||
>
|
>
|
||||||
{leftActions}
|
{leftActions}
|
||||||
</div>
|
</div>
|
||||||
@ -122,7 +138,7 @@ export function SwipeableRow({
|
|||||||
<div
|
<div
|
||||||
aria-hidden={offset >= 0}
|
aria-hidden={offset >= 0}
|
||||||
className="absolute inset-y-0 right-0 flex items-stretch"
|
className="absolute inset-y-0 right-0 flex items-stretch"
|
||||||
style={{ width: SHELF_WIDTH }}
|
style={{ width: rightWidth }}
|
||||||
>
|
>
|
||||||
{rightActions}
|
{rightActions}
|
||||||
</div>
|
</div>
|
||||||
@ -153,10 +169,17 @@ export function SwipeableRow({
|
|||||||
*/
|
*/
|
||||||
export function snapPosition(
|
export function snapPosition(
|
||||||
offset: number,
|
offset: number,
|
||||||
shelves: { leftActions: boolean; rightActions: boolean },
|
shelves: {
|
||||||
|
leftActions: boolean;
|
||||||
|
rightActions: boolean;
|
||||||
|
leftWidth?: number;
|
||||||
|
rightWidth?: number;
|
||||||
|
},
|
||||||
): number {
|
): number {
|
||||||
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return SHELF_WIDTH;
|
const lw = shelves.leftWidth ?? SHELF_WIDTH;
|
||||||
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user