yiekheng 234e8aa690 fix(web,bot): drop next-themes, extend QR validity, fix retry CTA
next-themes hydration mismatch
- Removed the next-themes wrapper, ThemeProvider component, and the
  Settings appearance card — there's no theme-toggle UI anywhere in
  the app, so the library was just adding a pre-hydration `<script>`
  that triggered React 19's "script tag while rendering" warning and
  the `<html>` class swap caused the hydration mismatch.
- Sonner Toaster now uses a fixed `theme="light"` instead of useTheme.
- Layout drops `suppressHydrationWarning` on `<html>` since we no
  longer mutate it on mount.

QR refs exhausted before the user could scan
- Pass `qrTimeout: 60_000` to makeWASocket so each QR (first AND
  subsequent) lasts a full minute. Default was 60 s for the first and
  20 s for each subsequent → ~6 refs × default = ~2.5 min before
  Baileys gave up. With 60 s flat, the user has the full ~5 min
  window matching pair-handler's PAIR_TIMEOUT_MS.

Pairing-timed-out screen
- "Try again" used to link to /accounts/new (creates a new account
  instead of re-pairing the existing one). Link now points to the
  existing /accounts/[id] detail page where the operator can hit
  Re-pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:57:13 +08:00

224 lines
8.1 KiB
TypeScript

"use client";
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 { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useEvents } from "@/hooks/use-events";
import { countdownRender } from "@/lib/qr-dedupe";
type PairingState =
| { phase: "waiting" }
| { phase: "qr"; qrUrl: string }
| { phase: "connected"; phoneNumber: string }
| { phase: "timeout" };
interface PairLiveProps {
accountId: string;
label: string;
}
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
const { pct, danger, expired } = countdownRender(seconds, total);
const mm = Math.floor(seconds / 60);
const ss = String(seconds % 60).padStart(2, "0");
return (
<div className="flex w-full max-w-64 flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{expired ? "Pairing window expired" : "Pairing expires in"}
</span>
{!expired && (
<span
className={`font-mono tabular-nums font-medium ${
danger ? "text-destructive" : "text-foreground"
}`}
>
{mm}:{ss}
</span>
)}
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full transition-[width] duration-1000 ease-linear ${
danger ? "bg-destructive" : "bg-foreground/70"
}`}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
</div>
);
}
// Match the bot's PAIR_TIMEOUT_MS — 5 minutes — so the pairing-window
// timer is the single source of truth for "you have to scan by then".
// Individual QR rotations (~5 s each from Baileys) refresh the displayed
// QR but do NOT reset this timer — that would punish the user with a
// constantly-resetting countdown.
const PAIRING_WINDOW_SEC = 5 * 60;
export function PairLive({ accountId, label }: PairLiveProps) {
const router = useRouter();
const [pairingState, setPairingState] = useState<PairingState>({ phase: "waiting" });
const [countdown, setCountdown] = useState(PAIRING_WINDOW_SEC);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startedRef = useRef(false);
/** Start the pairing-window countdown once. Subsequent QR refreshes do
* not restart this — only mount/connect/timeout/unmount touches it. */
const startCountdown = () => {
if (startedRef.current) return;
startedRef.current = true;
if (timerRef.current) clearInterval(timerRef.current);
setCountdown(PAIRING_WINDOW_SEC);
timerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return 0;
}
return c - 1;
});
}, 1000);
};
useEvents({
"session.qr": (data) => {
if (data.accountId !== accountId) return;
// Bust the URL with the timestamp so the browser refetches each time.
setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` });
// Idempotent — only the first QR starts the global pairing-window
// timer; later QR rotations leave it ticking.
startCountdown();
},
"session.connected": (data) => {
if (data.accountId !== accountId) return;
if (timerRef.current) clearInterval(timerRef.current);
setPairingState({
phase: "connected",
phoneNumber: data.phoneNumber ?? "",
});
},
"session.timeout": (data) => {
if (data.accountId !== accountId) return;
if (timerRef.current) clearInterval(timerRef.current);
setPairingState({ phase: "timeout" });
},
});
// Auto-redirect on connected
useEffect(() => {
if (pairingState.phase !== "connected") return;
const t = setTimeout(() => {
router.push(`/accounts/${accountId}` as never);
}, 3000);
return () => clearTimeout(t);
}, [pairingState.phase, accountId, router]);
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
return (
<div className="flex flex-col items-center gap-6 py-4">
{/* Label chip */}
<div className="flex items-center gap-1.5 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-medium text-muted-foreground">
<ScanLineIcon className="size-3" />
{label}
</div>
{/* State display */}
{pairingState.phase === "waiting" && (
<div className="flex flex-col items-center gap-3">
<Skeleton className="size-64 rounded-lg" />
<p className="text-sm text-muted-foreground">Generating QR</p>
</div>
)}
{pairingState.phase === "qr" && (
<div className="flex flex-col items-center gap-4">
{/* Pairing-window countdown — single source of truth.
The QR image rotates every ~5 s but this timer keeps ticking
against the bot's PAIR_TIMEOUT (5 min). */}
<CountdownBar seconds={countdown} total={PAIRING_WINDOW_SEC} />
{/* QR image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={pairingState.qrUrl}
alt="WhatsApp QR code"
width={256}
height={256}
className="rounded-lg ring-1 ring-foreground/10"
/>
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm font-medium">Scan with WhatsApp Linked Devices</p>
<p className="text-xs text-muted-foreground">
Open WhatsApp tap Linked Devices Link a device
</p>
<p className="text-xs text-muted-foreground/80 italic">
The QR rotates automatically every few seconds scan whichever one is showing.
</p>
<Button asChild variant="outline" size="sm" className="mt-1">
<a
href={pairingState.qrUrl}
download={`whatsapp-qr-${label.replace(/\s+/g, "-").toLowerCase() || accountId}.png`}
>
<DownloadIcon />
Save QR
</a>
</Button>
</div>
</div>
)}
{pairingState.phase === "connected" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2Icon className="size-8 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Account connected!</p>
{pairingState.phoneNumber && (
<p className="text-sm text-muted-foreground">
Connected as{" "}
<span className="font-mono font-medium text-foreground">
+{pairingState.phoneNumber.replace(/^\+/, "")}
</span>
</p>
)}
<p className="text-xs text-muted-foreground">Redirecting in 3 seconds</p>
</div>
</div>
)}
{pairingState.phase === "timeout" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircleIcon className="size-8 text-destructive" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Pairing timed out</p>
<p className="text-xs text-muted-foreground">
The QR window closed before a device was linked.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${accountId}` as any}>
Back to account try again
</Link>
</Button>
</div>
)}
</div>
);
}