import { Button } from "@workspace/ui/components/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@workspace/ui/components/dialog"; import { Icon } from "@workspace/ui/composed/icon"; import { generateCaptcha, verifyCaptchaSlider, } from "@workspace/ui/services/common/auth"; import { type RefObject, useCallback, useImperativeHandle, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; export interface SliderCaptchaRef { reset: () => void; } interface SliderCaptchaProps { value?: string; onChange?: (value: string) => void; } interface TrailPoint { x: number; y: number; t: number; } const BLOCK_SIZE = 100; const BG_NATURAL_WIDTH = 560; const BG_NATURAL_HEIGHT = 280; const SliderCaptcha = ({ onChange, ref, }: SliderCaptchaProps & { ref?: RefObject }) => { const { t } = useTranslation("auth"); const containerRef = useRef(null); const [open, setOpen] = useState(false); const [captchaId, setCaptchaId] = useState(""); const [bgImage, setBgImage] = useState(""); const [blockImage, setBlockImage] = useState(""); const [loading, setLoading] = useState(false); const [verified, setVerified] = useState(false); const [status, setStatus] = useState<"idle" | "success" | "fail">("idle"); const [blockPos, setBlockPos] = useState({ x: 0, y: 50 }); const [shaking, setShaking] = useState(false); const dragging = useRef(false); const startPointer = useRef({ x: 0, y: 0 }); const startBlock = useRef({ x: 0, y: 50 }); const dragStartTime = useRef(0); const trail = useRef([]); const getContainerSize = () => { const el = containerRef.current; if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT }; return { w: el.clientWidth, h: el.clientHeight }; }; const fetchCaptcha = useCallback(async () => { setLoading(true); setStatus("idle"); setBlockPos({ x: 0, y: 50 }); trail.current = []; try { const res = await generateCaptcha(); const data = res.data?.data; if (data) { setCaptchaId(data.id); setBgImage(data.image); setBlockImage(data.block_image ?? ""); } } catch (e) { console.error("Failed to generate slider captcha:", e); } finally { setLoading(false); } }, []); const handleOpen = () => { if (verified) return; setOpen(true); fetchCaptcha(); }; useImperativeHandle(ref, () => ({ reset: () => { setVerified(false); onChange?.(""); setOpen(false); trail.current = []; }, })); const onPointerDown = (e: React.PointerEvent) => { if (status === "success" || loading) return; dragging.current = true; dragStartTime.current = Date.now(); trail.current = []; startPointer.current = { x: e.clientX, y: e.clientY }; startBlock.current = { ...blockPos }; (e.target as HTMLElement).setPointerCapture(e.pointerId); e.preventDefault(); trail.current.push({ x: Math.round(startBlock.current.x), y: Math.round(startBlock.current.y), t: 0, }); }; const onPointerMove = (e: React.PointerEvent) => { if (!dragging.current) return; const { w, h } = getContainerSize(); const scaleX = BG_NATURAL_WIDTH / w; const scaleY = BG_NATURAL_HEIGHT / h; const dx = (e.clientX - startPointer.current.x) * scaleX; const dy = (e.clientY - startPointer.current.y) * scaleY; const newX = Math.max( 0, Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE) ); const newY = Math.max( 0, Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE) ); setBlockPos({ x: newX, y: newY }); trail.current.push({ x: Math.round(newX), y: Math.round(newY), t: Date.now() - dragStartTime.current, }); }; const onPointerUp = async (e: React.PointerEvent) => { if (!dragging.current) return; dragging.current = false; const { w, h } = getContainerSize(); const scaleX = BG_NATURAL_WIDTH / w; const scaleY = BG_NATURAL_HEIGHT / h; const dx = (e.clientX - startPointer.current.x) * scaleX; const dy = (e.clientY - startPointer.current.y) * scaleY; const finalX = Math.round( Math.max( 0, Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE) ) ); const finalY = Math.round( Math.max( 0, Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE) ) ); setBlockPos({ x: finalX, y: finalY }); trail.current.push({ x: finalX, y: finalY, t: Date.now() - dragStartTime.current, }); try { const res = await verifyCaptchaSlider({ id: captchaId, x: finalX, y: finalY, trail: JSON.stringify(trail.current), }); const token = res.data?.data?.token; if (token) { setStatus("success"); setTimeout(() => { setVerified(true); onChange?.(token); setOpen(false); }, 600); } else { triggerFail(); } } catch { triggerFail(); } }; const triggerFail = () => { setStatus("fail"); setShaking(true); setTimeout(() => { setShaking(false); fetchCaptcha(); }, 800); }; const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100; const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100; const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100; const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100; return ( <> {/* Trigger button */} {/* Slider dialog */} { if (!o) setOpen(false); }} open={open} > {t("captcha.slider.title", "Security Verification")}
{loading ? (
) : bgImage ? ( <> captcha background {blockImage && ( captcha block )} {status !== "idle" && (
{status === "success" ? t("captcha.slider.success", "Verified") : t("captcha.slider.fail", "Try again")}
)} ) : null}

{t("captcha.slider.hint", "Drag the piece to fit the puzzle")}

); }; SliderCaptcha.displayName = "SliderCaptcha"; export default SliderCaptcha;