Drop useEffect from VerifyCodeForm; replace tick-based countdown

The per-second countdown was the only thing pulling useEffect into this
component, in violation of the project rule (CLAUDE.md: avoid useEffect,
prefer event handlers). The hook was driving a tick purely so we could
show "Resend in 28s" / "27s" / ... — none of which is load-bearing.

Replace it with a single setTimeout fired from the resend event handler
that flips resendStatus from "cooldown" back to "idle" after 30 seconds.
The button stays disabled with "Code sent — wait a moment" instead of
showing a live countdown. Same blocking signal, no hook needed.

While here, split the single status enum into verifyStatus + resendStatus
so the verify button no longer wrongly disables during a resend cooldown
(latent bug from the previous shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 14:50:05 +00:00
parent ee506425fd
commit 47efd1bd5a

View file

@ -1,36 +1,33 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
const RESEND_COOLDOWN_SECONDS = 30;
const RESEND_COOLDOWN_MS = 30_000;
type Status = "idle" | "verifying" | "resending";
type VerifyStatus = "idle" | "verifying";
type ResendStatus = "idle" | "resending" | "cooldown";
export default function VerifyCodeForm({ email }: { email: string }) {
const router = useRouter();
const [code, setCode] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>("idle");
const [resendStatus, setResendStatus] = useState<ResendStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [resendCountdown, setResendCountdown] = useState(0);
const [resendNotice, setResendNotice] = useState<string | null>(null);
const inFlightRef = useRef(false);
// Resend cooldown timer
useEffect(() => {
if (resendCountdown <= 0) return;
const id = setTimeout(() => setResendCountdown((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [resendCountdown]);
const isWorking =
verifyStatus === "verifying" || resendStatus === "resending";
async function submitCode(value: string) {
if (inFlightRef.current) return;
if (!/^\d{6}$/.test(value)) return;
inFlightRef.current = true;
setStatus("verifying");
setVerifyStatus("verifying");
setError(null);
const res = await signIn("email-code", {
@ -46,7 +43,7 @@ export default function VerifyCodeForm({ email }: { email: string }) {
return;
}
setStatus("idle");
setVerifyStatus("idle");
setCode("");
setError(
"That code didn't match. Check the latest email — older codes stop working as soon as you request a new one.",
@ -62,25 +59,33 @@ export default function VerifyCodeForm({ email }: { email: string }) {
}
async function handleResend() {
if (resendCountdown > 0 || status === "resending") return;
if (!email) return;
setStatus("resending");
if (isWorking || resendStatus === "cooldown" || !email) return;
setResendStatus("resending");
setError(null);
setResendNotice(null);
const res = await signIn("email", { email, redirect: false });
setStatus("idle");
if (res?.error) {
setResendStatus("idle");
setError(
"We couldn't send a new code right now. Wait a minute and try again.",
);
return;
}
setResendNotice("A new code is on its way. Older codes stop working.");
setResendCountdown(RESEND_COOLDOWN_SECONDS);
setResendStatus("cooldown");
setTimeout(() => setResendStatus("idle"), RESEND_COOLDOWN_MS);
}
const resendLabel =
resendStatus === "resending"
? "Sending…"
: resendStatus === "cooldown"
? "Code sent — wait a moment"
: "Resend code";
return (
<div className="space-y-4 text-left">
<div>
@ -98,7 +103,7 @@ export default function VerifyCodeForm({ email }: { email: string }) {
onChange={(e) => handleChange(e.target.value)}
placeholder="••••••"
className="h-12 text-center text-2xl tracking-[0.5em] font-mono"
disabled={status === "verifying"}
disabled={verifyStatus === "verifying"}
autoFocus
/>
</div>
@ -106,10 +111,10 @@ export default function VerifyCodeForm({ email }: { email: string }) {
<Button
type="button"
onClick={() => submitCode(code)}
disabled={status !== "idle" || code.length !== 6}
disabled={isWorking || code.length !== 6}
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
>
{status === "verifying" ? "Verifying…" : "Sign in"}
{verifyStatus === "verifying" ? "Verifying…" : "Sign in"}
</Button>
<div className="text-center text-sm space-y-2">
@ -120,19 +125,12 @@ export default function VerifyCodeForm({ email }: { email: string }) {
<button
type="button"
onClick={handleResend}
disabled={
resendCountdown > 0 || status === "resending" || !email
}
disabled={isWorking || resendStatus === "cooldown" || !email}
className="text-brandblue hover:underline disabled:text-gray-400 disabled:no-underline"
>
{status === "resending"
? "Sending…"
: resendCountdown > 0
? `Resend code in ${resendCountdown}s`
: "Resend code"}
{resendLabel}
</button>
</div>
</div>
);
}