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>
224 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|