UNPKG

drag-puzzle-captcha

Version:

A beautiful, modern drag-and-drop puzzle CAPTCHA component for React applications

433 lines (370 loc) 14 kB
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react'; import './DragPuzzleCaptcha.css'; const DragPuzzleCaptcha = forwardRef(({ onVerify, language = "eng", showModal = false, onCloseModal }, ref) => { const [isVerified, setIsVerified] = useState(false); const [isDragging, setIsDragging] = useState(false); const [sliderPosition, setSliderPosition] = useState(0); const [puzzlePosition, setPuzzlePosition] = useState(0); const [backgroundImage, setBackgroundImage] = useState(''); const [puzzleImage, setPuzzleImage] = useState(''); const [targetPosition, setTargetPosition] = useState(0); const [targetY, setTargetY] = useState(0); const [puzzleY, setPuzzleY] = useState(0); const [attempts, setAttempts] = useState(0); const [isLoading, setIsLoading] = useState(true); const sliderRef = useRef(null); const containerRef = useRef(null); const puzzleRef = useRef(null); const modalRef = useRef(null); const currentPositionRef = useRef(0); // Text localization const getText = (key) => { const texts = { eng: { title: "Security Verification", instruction: "Drag the piece to complete the puzzle", dragText: "Drag to fit the piece", successText: "Verified successfully!", newPuzzle: "New puzzle", attempts: "Attempts", loading: "Loading puzzle..." }, fr: { title: "Vérification de sécurité", instruction: "Glissez la pièce pour compléter le puzzle", dragText: "Glissez pour ajuster la pièce", successText: "Vérifié avec succès!", newPuzzle: "Nouveau puzzle", attempts: "Tentatives", loading: "Chargement du puzzle..." } }; return texts[language]?.[key] || texts.eng[key]; }; useImperativeHandle(ref, () => ({ reset: () => { resetPuzzle(); }, isVerified: () => isVerified, openModal: () => { // This will be handled by the parent component } })); // Close modal when clicking outside useEffect(() => { const handleClickOutside = (event) => { if (modalRef.current && !modalRef.current.contains(event.target)) { if (onCloseModal && !isVerified) { onCloseModal(); } } }; if (showModal) { document.addEventListener('mousedown', handleClickOutside); document.body.style.overflow = 'hidden'; // Prevent background scrolling } return () => { document.removeEventListener('mousedown', handleClickOutside); document.body.style.overflow = 'unset'; }; }, [showModal, isVerified, onCloseModal]); // Generate random puzzle images const generatePuzzle = () => { setIsLoading(true); // Create a canvas to generate background and puzzle images const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 340; canvas.height = 180; // Generate a more sophisticated background pattern const gradient = ctx.createLinearGradient(0, 0, 340, 180); const hue1 = Math.random() * 360; const hue2 = (hue1 + 60 + Math.random() * 60) % 360; const hue3 = (hue1 + 120 + Math.random() * 60) % 360; gradient.addColorStop(0, `hsl(${hue1}, 65%, 55%)`); gradient.addColorStop(0.5, `hsl(${hue2}, 70%, 50%)`); gradient.addColorStop(1, `hsl(${hue3}, 65%, 60%)`); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 340, 180); // Add geometric patterns for visual complexity ctx.globalCompositeOperation = 'multiply'; // Add diagonal stripes for (let i = 0; i < 15; i++) { ctx.strokeStyle = `hsla(${Math.random() * 360}, 60%, 70%, 0.1)`; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(i * 30 - 100, 0); ctx.lineTo(i * 30 + 100, 180); ctx.stroke(); } ctx.globalCompositeOperation = 'source-over'; // Add some random geometric shapes for (let i = 0; i < 8; i++) { ctx.fillStyle = `hsla(${Math.random() * 360}, 70%, 60%, 0.2)`; ctx.beginPath(); if (Math.random() > 0.5) { // Circles ctx.arc(Math.random() * 340, Math.random() * 180, Math.random() * 40 + 15, 0, Math.PI * 2); } else { // Rectangles const x = Math.random() * 300; const y = Math.random() * 140; const w = Math.random() * 60 + 20; const h = Math.random() * 60 + 20; ctx.rect(x, y, w, h); } ctx.fill(); } // Add subtle noise texture for (let i = 0; i < 200; i++) { ctx.fillStyle = `rgba(${Math.random() > 0.5 ? 255 : 0}, ${Math.random() > 0.5 ? 255 : 0}, ${Math.random() > 0.5 ? 255 : 0}, 0.05)`; ctx.fillRect(Math.random() * 340, Math.random() * 180, 1, 1); } const backgroundImageUrl = canvas.toDataURL(); setBackgroundImage(backgroundImageUrl); // Generate puzzle piece position (adjusted for new dimensions) - calculate once const newTargetPosition = Math.random() * (340 - 70) + 35; // 70px puzzle width, 35px margin const sharedY = Math.random() * (180 - 70); // Same Y for both hole and piece setTargetPosition(newTargetPosition); setTargetY(sharedY); setPuzzleY(sharedY); // Same Y position as the hole // Create puzzle piece image with more sophisticated design const puzzleCanvas = document.createElement('canvas'); const puzzleCtx = puzzleCanvas.getContext('2d'); puzzleCanvas.width = 70; puzzleCanvas.height = 70; // Create puzzle piece with more realistic shape const puzzleGradient = puzzleCtx.createRadialGradient(35, 35, 10, 35, 35, 35); puzzleGradient.addColorStop(0, '#4a5568'); puzzleGradient.addColorStop(0.7, '#2d3748'); puzzleGradient.addColorStop(1, '#1a202c'); puzzleCtx.fillStyle = puzzleGradient; puzzleCtx.fillRect(0, 0, 70, 70); // Add puzzle piece connector puzzleCtx.fillStyle = '#e2e8f0'; puzzleCtx.beginPath(); puzzleCtx.arc(35, 35, 25, 0, Math.PI * 2); puzzleCtx.fill(); // Add inner detail puzzleCtx.fillStyle = '#cbd5e0'; puzzleCtx.beginPath(); puzzleCtx.arc(35, 35, 15, 0, Math.PI * 2); puzzleCtx.fill(); // Add center dot puzzleCtx.fillStyle = '#4a5568'; puzzleCtx.beginPath(); puzzleCtx.arc(35, 35, 8, 0, Math.PI * 2); puzzleCtx.fill(); // Add highlight puzzleCtx.fillStyle = '#f7fafc'; puzzleCtx.beginPath(); puzzleCtx.arc(30, 30, 4, 0, Math.PI * 2); puzzleCtx.fill(); const puzzleImageUrl = puzzleCanvas.toDataURL(); setPuzzleImage(puzzleImageUrl); setIsLoading(false); }; useEffect(() => { generatePuzzle(); }, []); const resetPuzzle = () => { setIsVerified(false); setSliderPosition(0); setPuzzlePosition(0); setAttempts(0); setTargetY(0); setPuzzleY(0); generatePuzzle(); }; const handleMouseDown = (e) => { if (isVerified) return; setIsDragging(true); e.preventDefault(); }; const handleMouseMove = (e) => { if (!isDragging || isVerified) return; const container = containerRef.current; const rect = container.getBoundingClientRect(); const newPosition = Math.max(0, Math.min(e.clientX - rect.left - 22, 318)); // 22px for half slider width, 318px max (340 - 22) setSliderPosition(newPosition); setPuzzlePosition(newPosition); // Store the current position for verification currentPositionRef.current = newPosition; }; const handleMouseUp = () => { if (!isDragging) return; setIsDragging(false); // Use the stored position from the last mouse move event const currentPosition = currentPositionRef.current; // Check if puzzle is in correct position (increased tolerance for better UX) const tolerance = 20; const distance = Math.abs(currentPosition - targetPosition); const isCorrect = distance < tolerance; console.log('JSX Verification check:', { currentPosition, puzzlePosition, // Also log state position for comparison targetPosition, distance, tolerance, isCorrect }); if (isCorrect) { setIsVerified(true); // Update positions to the current position to ensure consistency setSliderPosition(currentPosition); setPuzzlePosition(currentPosition); // Add success animation to modal if (modalRef.current) { modalRef.current.classList.add('success-animation'); setTimeout(() => { modalRef.current?.classList.remove('success-animation'); }, 800); } if (onVerify) { onVerify(true); } // Close modal after successful verification setTimeout(() => { if (onCloseModal) { onCloseModal(); } }, 2000); // Increased delay to show success state } else { setAttempts(prev => { const newAttempts = prev + 1; console.log('JSX Failed attempt:', newAttempts); return newAttempts; }); // Reset position after failed attempt setTimeout(() => { setSliderPosition(0); setPuzzlePosition(0); }, 800); // Increased delay to show feedback if (onVerify) { onVerify(false); } // Generate new puzzle after 3 failed attempts if (attempts >= 2) { setTimeout(() => { resetPuzzle(); }, 1000); } } }; useEffect(() => { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, puzzlePosition, targetPosition, attempts]); if (!showModal) { return null; } if (isLoading) { return ( <div className="drag-puzzle-modal-overlay"> <div className="drag-puzzle-modal" ref={modalRef}> <div className="drag-puzzle-modal-header"> <h3>{getText('title')}</h3> <button className="drag-puzzle-close-btn" onClick={onCloseModal} > × </button> </div> <div className="drag-puzzle-container"> <div className="drag-puzzle-loading"> {getText('loading')} </div> </div> </div> </div> ); } return ( <div className="drag-puzzle-modal-overlay"> <div className="drag-puzzle-modal" ref={modalRef}> <div className="drag-puzzle-modal-header"> <h3>{getText('title')}</h3> <button className="drag-puzzle-close-btn" onClick={onCloseModal} disabled={isVerified} > × </button> </div> <div className="drag-puzzle-container"> <div className="drag-puzzle-title"> {getText('instruction')} </div> <div className="drag-puzzle-game" ref={containerRef}> {/* Background image with missing piece */} <div className="drag-puzzle-background" style={{ backgroundImage: `url(${backgroundImage})` }} > {/* Missing piece hole */} <div className="drag-puzzle-hole" style={{ left: `${targetPosition}px`, top: `${targetY}px` }} /> {/* Floating puzzle piece */} <div className={`drag-puzzle-piece ${isVerified ? 'verified' : ''}`} ref={puzzleRef} style={{ left: `${puzzlePosition}px`, top: `${puzzleY}px`, backgroundImage: `url(${puzzleImage})` }} /> </div> {/* Slider track */} <div className="drag-puzzle-slider"> <div className="drag-puzzle-track"> <div className={`drag-puzzle-slider-button ${isDragging ? 'dragging' : ''} ${isVerified ? 'verified' : ''}`} style={{ left: `${sliderPosition}px` }} onMouseDown={handleMouseDown} > <span className="drag-puzzle-slider-icon"> {isVerified ? '✓' : '⇄'} </span> </div> </div> <div className="drag-puzzle-text"> {isVerified ? getText('successText') : getText('dragText') } </div> </div> </div> {attempts > 0 && !isVerified && ( <div className="drag-puzzle-attempts"> {getText('attempts')}: {attempts}/3 </div> )} <div className="drag-puzzle-actions"> <button onClick={resetPuzzle} className="drag-puzzle-refresh" disabled={isVerified} > 🔄 {getText('newPuzzle')} </button> </div> </div> </div> </div> ); }); DragPuzzleCaptcha.displayName = 'DragPuzzleCaptcha'; export default DragPuzzleCaptcha;