feat(web): pair / unpair / sync server actions + live QR page
This commit is contained in:
parent
de21edd905
commit
68b46f8d71
114
apps/web/src/actions/accounts.ts
Normal file
114
apps/web/src/actions/accounts.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { whatsappAccounts } from "@cmbot/db";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { pgNotifyBot } from "@/lib/notify";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
async function rateLimit(key: string) {
|
||||||
|
const h = await headers();
|
||||||
|
const ip =
|
||||||
|
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
h.get("x-real-ip") ??
|
||||||
|
"unknown";
|
||||||
|
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
|
||||||
|
if (r.limited) throw new Error("Too many requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairSchema = z.object({
|
||||||
|
label: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Label is required")
|
||||||
|
.max(60, "Label too long (max 60)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PairResult =
|
||||||
|
| { ok: true; accountId: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function pairAccountAction(
|
||||||
|
_prev: unknown,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<PairResult> {
|
||||||
|
await rateLimit("pair");
|
||||||
|
const parsed = pairSchema.safeParse({ label: formData.get("label") });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: parsed.error.issues[0]?.message ?? "Invalid label",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const existing = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) =>
|
||||||
|
and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)),
|
||||||
|
});
|
||||||
|
if (existing && existing.status === "connected") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `"${parsed.data.label}" is already connected. Unpair first.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let accountId = existing?.id;
|
||||||
|
if (!accountId) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(whatsappAccounts)
|
||||||
|
.values({
|
||||||
|
operatorId: op.id,
|
||||||
|
label: parsed.data.label,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning({ id: whatsappAccounts.id });
|
||||||
|
accountId = created!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pgNotifyBot({ type: "account.start_pairing", accountId });
|
||||||
|
revalidatePath("/accounts");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
redirect(`/accounts/${accountId}/pairing` as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpairAccountAction(formData: FormData): Promise<void> {
|
||||||
|
await rateLimit("unpair");
|
||||||
|
const accountId = formData.get("accountId");
|
||||||
|
if (typeof accountId !== "string") return;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) =>
|
||||||
|
and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||||
|
});
|
||||||
|
if (!account) return;
|
||||||
|
await pgNotifyBot({ type: "account.unpair", accountId });
|
||||||
|
// Optimistic UI — bot will overwrite status via the same column
|
||||||
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ status: "logged_out", phoneNumber: null })
|
||||||
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
|
revalidatePath("/accounts");
|
||||||
|
revalidatePath(`/accounts/${accountId}`);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
redirect("/accounts" as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGroupsAction(formData: FormData): Promise<void> {
|
||||||
|
await rateLimit("sync");
|
||||||
|
const accountId = formData.get("accountId");
|
||||||
|
if (typeof accountId !== "string") return;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) =>
|
||||||
|
and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||||
|
});
|
||||||
|
if (!account) return;
|
||||||
|
await pgNotifyBot({ type: "account.sync_groups", accountId });
|
||||||
|
revalidatePath(`/accounts/${accountId}`);
|
||||||
|
revalidatePath(`/accounts/${accountId}/groups`);
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getAccount } from "@/lib/queries";
|
import { getAccount } from "@/lib/queries";
|
||||||
|
import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts";
|
||||||
|
|
||||||
interface AccountDetailPageProps {
|
interface AccountDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -104,8 +105,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* No-op placeholder — wired in Task 17 */}
|
<form action={syncGroupsAction}>
|
||||||
<form action={async () => { "use server"; }}>
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<Button type="submit" variant="outline" size="sm">
|
||||||
<RefreshCwIcon />
|
<RefreshCwIcon />
|
||||||
Sync
|
Sync
|
||||||
@ -144,8 +145,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter showCloseButton>
|
||||||
{/* No-op placeholder — wired in Task 17 */}
|
<form action={unpairAccountAction}>
|
||||||
<form action={async () => { "use server"; }}>
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
Yes, unpair
|
Yes, unpair
|
||||||
|
|||||||
56
apps/web/src/app/accounts/[id]/pairing/page.tsx
Normal file
56
apps/web/src/app/accounts/[id]/pairing/page.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { PairLive } from "@/components/pair-live";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getAccount } from "@/lib/queries";
|
||||||
|
|
||||||
|
interface PairingPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PairingPage({ params }: PairingPageProps) {
|
||||||
|
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-lg mx-auto space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/accounts" as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Accounts
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Waiting for WhatsApp pairing…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live QR card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scan QR code</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A QR code will appear below. Scan it with WhatsApp on your phone to link this account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PairLive accountId={account.id} label={account.label} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/app/accounts/new/page.tsx
Normal file
53
apps/web/src/app/accounts/new/page.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeftIcon, SmartphoneIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { PairForm } from "@/components/pair-form";
|
||||||
|
|
||||||
|
export default function NewAccountPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-lg mx-auto space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/accounts" as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Accounts
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<SmartphoneIcon className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Pair New Account</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Link a WhatsApp number to start scheduling reminders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Give this account a short label so you can identify it later. You can pair
|
||||||
|
multiple numbers — one per account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PairForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/src/components/pair-form.tsx
Normal file
76
apps/web/src/components/pair-form.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRightIcon, Loader2Icon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { pairAccountAction, type PairResult } from "@/actions/accounts";
|
||||||
|
|
||||||
|
const initialState: PairResult = { ok: true, accountId: "" };
|
||||||
|
|
||||||
|
export function PairForm() {
|
||||||
|
const [state, action, isPending] = useActionState(pairAccountAction, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="label" className="text-sm font-medium">
|
||||||
|
Account label
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="label"
|
||||||
|
name="label"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Personal, Work, Support"
|
||||||
|
maxLength={60}
|
||||||
|
required
|
||||||
|
aria-invalid={state.ok === false ? true : undefined}
|
||||||
|
aria-describedby={state.ok === false ? "label-error" : undefined}
|
||||||
|
className="h-9"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{state.ok === false && (
|
||||||
|
<p
|
||||||
|
id="label-error"
|
||||||
|
role="alert"
|
||||||
|
className="flex items-center gap-1.5 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
<span className="inline-block size-1 rounded-full bg-destructive shrink-0" />
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A short name to identify this WhatsApp account. You can have multiple accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
Starting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Start Pairing
|
||||||
|
<ArrowRightIcon className="size-3.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild variant="ghost" size="default">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/accounts" as any}>Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
apps/web/src/components/pair-live.tsx
Normal file
215
apps/web/src/components/pair-live.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useEvents } from "@/hooks/use-events";
|
||||||
|
|
||||||
|
type PairingState =
|
||||||
|
| { phase: "waiting" }
|
||||||
|
| { phase: "qr"; qrPng: string }
|
||||||
|
| { phase: "connected"; phoneNumber: string }
|
||||||
|
| { phase: "timeout" };
|
||||||
|
|
||||||
|
interface PairLiveProps {
|
||||||
|
accountId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG countdown ring. radius=54 so circumference ≈ 339.3 */
|
||||||
|
function CountdownRing({ seconds, total }: { seconds: number; total: number }) {
|
||||||
|
const r = 54;
|
||||||
|
const circ = 2 * Math.PI * r;
|
||||||
|
const progress = seconds / total;
|
||||||
|
const dash = circ * progress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 -rotate-90"
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{/* Track */}
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="3"
|
||||||
|
className="stroke-muted"
|
||||||
|
/>
|
||||||
|
{/* Remaining */}
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray={`${dash} ${circ}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="stroke-foreground transition-[stroke-dasharray] duration-1000 ease-linear"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNTDOWN_TOTAL = 30;
|
||||||
|
|
||||||
|
export function PairLive({ accountId, label }: PairLiveProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pairingState, setPairingState] = useState<PairingState>({ phase: "waiting" });
|
||||||
|
const [countdown, setCountdown] = useState(COUNTDOWN_TOTAL);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Reset and start countdown when QR arrives
|
||||||
|
const startCountdown = () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
setCountdown(COUNTDOWN_TOTAL);
|
||||||
|
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;
|
||||||
|
setPairingState({ phase: "qr", qrPng: data.qrPng });
|
||||||
|
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">
|
||||||
|
{/* QR + ring overlay */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Countdown ring positioned around the QR */}
|
||||||
|
<div className="absolute -inset-5 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="relative size-32">
|
||||||
|
<CountdownRing seconds={countdown} total={COUNTDOWN_TOTAL} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs tabular-nums font-medium transition-colors ${
|
||||||
|
countdown <= 10 ? "text-destructive" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{countdown}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR image */}
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${pairingState.qrPng}`}
|
||||||
|
alt="WhatsApp QR code"
|
||||||
|
width={256}
|
||||||
|
height={256}
|
||||||
|
className="rounded-lg ring-1 ring-foreground/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1 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>
|
||||||
|
</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 code expired before a device was linked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/accounts/new" as any}>Try again</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user