feat(web): show a spinner while the first QR is being generated
The pairing page used to show a static skeleton block before the bot pushed the first session.qr event through SSE — visually quiet, easy to mistake for a stalled page. Replace it with a labelled, accessible spinner: - lucide Loader2 icon with Tailwind animate-spin - role="status" + aria-live="polite" + aria-label="Generating QR code" so assistive tech announces it as soon as the page loads - Same size-64 footprint as the rendered QR — no layout jump when the image lands Tests (+5, 104 passing total): - pair-live.test.tsx: covers the initial 'waiting' state — spinner attributes, animated icon, helper text, no premature QR/countdown/ Save button, and the size-64 placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c9cb75111
commit
34f22a4f24
78
apps/web/src/components/pair-live.test.tsx
Normal file
78
apps/web/src/components/pair-live.test.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// PairLive uses these client-only modules. Stub them so the component
|
||||
// can be rendered server-side for assertion. The default state on first
|
||||
// render is `phase: "waiting"` — exactly the case we want to verify here.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record<string, unknown>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
// useEvents subscribes to the SSE stream; on the server-render side we
|
||||
// just want it to no-op so the component stays in its initial state.
|
||||
vi.mock("@/hooks/use-events", () => ({
|
||||
useEvents: () => {},
|
||||
}));
|
||||
|
||||
import { PairLive } from "./pair-live";
|
||||
|
||||
describe("PairLive — initial 'waiting' state shows a spinner placeholder", () => {
|
||||
it("renders an aria-live spinner block before any QR arrives", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PairLive accountId="acc-1" label="Personal" />,
|
||||
);
|
||||
|
||||
// The placeholder is a labelled, aria-live region so screen readers
|
||||
// announce it as soon as it appears.
|
||||
expect(html).toMatch(/data-testid="pair-loading"/);
|
||||
expect(html).toMatch(/role="status"/);
|
||||
expect(html).toMatch(/aria-live="polite"/);
|
||||
expect(html).toMatch(/aria-label="Generating QR code"/);
|
||||
});
|
||||
|
||||
it("uses an animated spinner icon (lucide Loader2)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PairLive accountId="acc-1" label="Personal" />,
|
||||
);
|
||||
// Lucide tags every icon with a stable `lucide-<name>` class.
|
||||
expect(html).toMatch(/lucide-loader-?2|lucide-loader/);
|
||||
// The animation hook (Tailwind's animate-spin) must be applied so
|
||||
// the spinner actually rotates.
|
||||
expect(html).toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("shows the 'Generating QR…' helper text", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PairLive accountId="acc-1" label="Personal" />,
|
||||
);
|
||||
expect(html).toContain("Generating QR…");
|
||||
});
|
||||
|
||||
it("does NOT yet render the QR image, countdown bar, or Save QR button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PairLive accountId="acc-1" label="Personal" />,
|
||||
);
|
||||
// No <img> for the QR.
|
||||
expect(html).not.toMatch(/<img[^>]+alt="WhatsApp QR code"/);
|
||||
// No "QR expires" / "Pairing expires" countdown copy.
|
||||
expect(html).not.toContain("Pairing expires");
|
||||
// No download CTA.
|
||||
expect(html).not.toContain("Save QR");
|
||||
});
|
||||
|
||||
it("placeholder occupies the same 64x64 footprint as the eventual QR", () => {
|
||||
// Avoids a layout jump when the QR finally lands. Both the spinner
|
||||
// tile and the rendered QR use Tailwind's `size-64` (16rem).
|
||||
const html = renderToStaticMarkup(
|
||||
<PairLive accountId="acc-1" label="Personal" />,
|
||||
);
|
||||
expect(html).toMatch(/class="[^"]*\bsize-64\b/);
|
||||
});
|
||||
});
|
||||
@ -3,9 +3,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
XCircleIcon,
|
||||
ScanLineIcon,
|
||||
DownloadIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEvents } from "@/hooks/use-events";
|
||||
import { countdownRender } from "@/lib/qr-dedupe";
|
||||
|
||||
@ -133,10 +138,21 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* State display */}
|
||||
{/* State display — spinner placeholder until the bot pushes the
|
||||
first session.qr event through SSE. The 64x64-tile area is the
|
||||
same size as the rendered QR so the layout doesn't jump when
|
||||
the image arrives. */}
|
||||
{pairingState.phase === "waiting" && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Skeleton className="size-64 rounded-lg" />
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Generating QR code"
|
||||
data-testid="pair-loading"
|
||||
className="flex flex-col items-center gap-3"
|
||||
>
|
||||
<div className="flex size-64 items-center justify-center rounded-lg border border-border bg-muted/30">
|
||||
<Loader2Icon className="size-10 text-muted-foreground animate-spin" aria-hidden />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Generating QR…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user