Files
pargar/frontend/src/components/captcha-field.tsx
T
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30

156 lines
4.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
expiresIn?: number;
};
const FALLBACK_SECONDS = 120;
export function CaptchaField({
id,
value,
onChange,
onChallenge,
onError,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onChallenge: (challenge: CaptchaChallenge | null) => void;
onError?: (message: string | null) => void;
}) {
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [left, setLeft] = useState(FALLBACK_SECONDS);
const gen = useRef(0);
const rotating = useRef(false);
const onChallengeRef = useRef(onChallenge);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
onChallengeRef.current = onChallenge;
onChangeRef.current = onChange;
onErrorRef.current = onError;
const load = useCallback(async (silent: boolean) => {
if (silent && rotating.current) return;
if (silent) rotating.current = true;
const ticket = ++gen.current;
if (!silent) setLoading(true);
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
if (ticket !== gen.current) return;
setChallenge(data);
onChallengeRef.current(data);
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
onChangeRef.current("");
onErrorRef.current?.(null);
} catch (err) {
if (ticket !== gen.current) return;
if (!silent) {
setChallenge(null);
onChallengeRef.current(null);
}
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
rotating.current = false;
if (ticket === gen.current && !silent) setLoading(false);
}
}, []);
useEffect(() => {
void load(false);
}, [load]);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) {
void load(true);
return n;
}
return n - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [challenge, load]);
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
const seconds = String(left % 60).padStart(2, "0");
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>کد امنیتی</Label>
<button
type="button"
onClick={() => void load(false)}
disabled={loading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
تازه‌سازی
{challenge && (
<span className="tabular-nums text-muted-foreground" dir="ltr">
{minutes}:{seconds}
</span>
)}
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{challenge?.imageBase64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
) : (
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
)}
{challenge && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
>
<span
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
/>
</span>
)}
</div>
<Input
id={id}
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
);
}