549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
import { useState, useCallback, DragEvent } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Card } from '../../ui/Card';
|
|
import { Button } from '../../ui/Button';
|
|
import {
|
|
CheckCircle,
|
|
XCircle,
|
|
RefreshCcw,
|
|
Link2,
|
|
GripVertical,
|
|
Trophy,
|
|
Star,
|
|
Target,
|
|
Zap
|
|
} from 'lucide-react';
|
|
|
|
export interface MatchingItem {
|
|
id: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface MatchingPair {
|
|
leftId: string;
|
|
rightId: string;
|
|
}
|
|
|
|
export interface MatchingExerciseProps {
|
|
leftItems: MatchingItem[];
|
|
rightItems: MatchingItem[];
|
|
correctPairs: MatchingPair[];
|
|
onComplete?: (result: MatchingResult) => void;
|
|
title?: string;
|
|
description?: string;
|
|
maxPoints?: number;
|
|
shuffleItems?: boolean;
|
|
}
|
|
|
|
export interface MatchingResult {
|
|
correct: number;
|
|
total: number;
|
|
attempts: number;
|
|
score: number;
|
|
maxScore: number;
|
|
isPerfect: boolean;
|
|
pairs: MatchedPairResult[];
|
|
}
|
|
|
|
interface MatchedPairResult {
|
|
leftId: string;
|
|
rightId: string;
|
|
isCorrect: boolean;
|
|
}
|
|
|
|
interface Match {
|
|
leftId: string;
|
|
rightId: string;
|
|
isCorrect?: boolean;
|
|
checked?: boolean;
|
|
}
|
|
|
|
// Fisher-Yates shuffle algorithm
|
|
function shuffleArray<T>(array: T[]): T[] {
|
|
const shuffled = [...array];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
export function MatchingExercise({
|
|
leftItems,
|
|
rightItems,
|
|
correctPairs,
|
|
onComplete,
|
|
title = 'Emparejamiento',
|
|
description = 'Relaciona los elementos de la columna izquierda con los de la derecha',
|
|
maxPoints = 100,
|
|
shuffleItems = true,
|
|
}: MatchingExerciseProps) {
|
|
// Initialize items with optional shuffle
|
|
const [displayLeftItems, setDisplayLeftItems] = useState<MatchingItem[]>(() =>
|
|
shuffleItems ? shuffleArray(leftItems) : leftItems
|
|
);
|
|
const [displayRightItems, setDisplayRightItems] = useState<MatchingItem[]>(() =>
|
|
shuffleItems ? shuffleArray(rightItems) : rightItems
|
|
);
|
|
|
|
const [matches, setMatches] = useState<Match[]>([]);
|
|
const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
|
|
const [selectedRight, setSelectedRight] = useState<string | null>(null);
|
|
const [attempts, setAttempts] = useState(0);
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [dragOverLeft, setDragOverLeft] = useState<string | null>(null);
|
|
const [dragOverRight, setDragOverRight] = useState<string | null>(null);
|
|
|
|
// Check if all pairs are matched
|
|
const allMatched = matches.length === leftItems.length;
|
|
|
|
// Calculate score based on attempts
|
|
const calculateScore = useCallback((correctCount: number, totalAttempts: number): number => {
|
|
if (correctCount === 0) return 0;
|
|
|
|
// Base score per correct match
|
|
const baseScore = maxPoints / leftItems.length;
|
|
|
|
// Penalty for attempts (starts at 100%, decreases with each attempt)
|
|
const efficiency = Math.max(0.5, 1 - (totalAttempts - leftItems.length) * 0.05);
|
|
|
|
return Math.round(correctCount * baseScore * efficiency);
|
|
}, [maxPoints, leftItems.length]);
|
|
|
|
// Handle left item click
|
|
const handleLeftClick = (itemId: string) => {
|
|
if (showResults) return;
|
|
|
|
if (selectedLeft === itemId) {
|
|
setSelectedLeft(null);
|
|
} else {
|
|
setSelectedLeft(itemId);
|
|
// If right is already selected, create match
|
|
if (selectedRight) {
|
|
handleCreateMatch(itemId, selectedRight);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle right item click
|
|
const handleRightClick = (itemId: string) => {
|
|
if (showResults) return;
|
|
|
|
if (selectedRight === itemId) {
|
|
setSelectedRight(null);
|
|
} else {
|
|
setSelectedRight(itemId);
|
|
// If left is already selected, create match
|
|
if (selectedLeft) {
|
|
handleCreateMatch(selectedLeft, itemId);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Create a new match
|
|
const handleCreateMatch = (leftId: string, rightId: string) => {
|
|
// Check if either item is already matched
|
|
const isLeftMatched = matches.some(m => m.leftId === leftId);
|
|
const isRightMatched = matches.some(m => m.rightId === rightId);
|
|
|
|
if (isLeftMatched || isRightMatched) return;
|
|
|
|
setMatches(prev => [...prev, { leftId, rightId }]);
|
|
setSelectedLeft(null);
|
|
setSelectedRight(null);
|
|
setAttempts(prev => prev + 1);
|
|
};
|
|
|
|
// Handle drag start
|
|
const handleDragStart = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({ itemId, side }));
|
|
e.dataTransfer.effectAllowed = 'link';
|
|
};
|
|
|
|
// Handle drag over
|
|
const handleDragOver = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'link';
|
|
if (side === 'left') {
|
|
setDragOverLeft(itemId);
|
|
} else {
|
|
setDragOverRight(itemId);
|
|
}
|
|
};
|
|
|
|
// Handle drag leave
|
|
const handleDragLeave = (side: 'left' | 'right') => {
|
|
if (side === 'left') {
|
|
setDragOverLeft(null);
|
|
} else {
|
|
setDragOverRight(null);
|
|
}
|
|
};
|
|
|
|
// Handle drop
|
|
const handleDrop = (e: DragEvent<HTMLDivElement>, targetId: string, targetSide: 'left' | 'right') => {
|
|
e.preventDefault();
|
|
|
|
try {
|
|
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
|
const { itemId: draggedId, side: draggedSide } = data;
|
|
|
|
// Prevent dropping on same side
|
|
if (draggedSide === targetSide) return;
|
|
|
|
// Create match
|
|
if (draggedSide === 'left' && targetSide === 'right') {
|
|
handleCreateMatch(draggedId, targetId);
|
|
} else if (draggedSide === 'right' && targetSide === 'left') {
|
|
handleCreateMatch(targetId, draggedId);
|
|
}
|
|
} catch {
|
|
// Invalid drag data
|
|
}
|
|
|
|
setDragOverLeft(null);
|
|
setDragOverRight(null);
|
|
};
|
|
|
|
// Remove a match
|
|
const handleRemoveMatch = (leftId: string) => {
|
|
if (showResults) return;
|
|
setMatches(prev => prev.filter(m => m.leftId !== leftId));
|
|
};
|
|
|
|
// Validate all matches
|
|
const handleValidate = () => {
|
|
const validatedMatches = matches.map(match => {
|
|
const isCorrect = correctPairs.some(
|
|
p => p.leftId === match.leftId && p.rightId === match.rightId
|
|
);
|
|
return { ...match, isCorrect, checked: true };
|
|
});
|
|
|
|
setMatches(validatedMatches);
|
|
setShowResults(true);
|
|
|
|
const correctCount = validatedMatches.filter(m => m.isCorrect).length;
|
|
const score = calculateScore(correctCount, attempts);
|
|
|
|
const result: MatchingResult = {
|
|
correct: correctCount,
|
|
total: leftItems.length,
|
|
attempts,
|
|
score,
|
|
maxScore: maxPoints,
|
|
isPerfect: correctCount === leftItems.length,
|
|
pairs: validatedMatches.map(m => ({
|
|
leftId: m.leftId,
|
|
rightId: m.rightId,
|
|
isCorrect: m.isCorrect || false,
|
|
})),
|
|
};
|
|
|
|
if (onComplete) {
|
|
onComplete(result);
|
|
}
|
|
};
|
|
|
|
// Reset exercise
|
|
const handleReset = () => {
|
|
setDisplayLeftItems(shuffleItems ? shuffleArray(leftItems) : leftItems);
|
|
setDisplayRightItems(shuffleItems ? shuffleArray(rightItems) : rightItems);
|
|
setMatches([]);
|
|
setSelectedLeft(null);
|
|
setSelectedRight(null);
|
|
setAttempts(0);
|
|
setShowResults(false);
|
|
setDragOverLeft(null);
|
|
setDragOverRight(null);
|
|
};
|
|
|
|
// Get matched item for display
|
|
const getMatchedRightItem = (leftId: string) => {
|
|
const match = matches.find(m => m.leftId === leftId);
|
|
if (!match) return null;
|
|
return rightItems.find(item => item.id === match.rightId);
|
|
};
|
|
|
|
const getMatchedLeftItem = (rightId: string) => {
|
|
const match = matches.find(m => m.rightId === rightId);
|
|
if (!match) return null;
|
|
return leftItems.find(item => item.id === match.leftId);
|
|
};
|
|
|
|
// Check if item is matched
|
|
const isLeftMatched = (id: string) => matches.some(m => m.leftId === id);
|
|
const isRightMatched = (id: string) => matches.some(m => m.rightId === id);
|
|
|
|
// Get match status
|
|
const getMatchStatus = (leftId: string): 'correct' | 'incorrect' | 'unchecked' | null => {
|
|
const match = matches.find(m => m.leftId === leftId);
|
|
if (!match || !showResults) return null;
|
|
return match.isCorrect ? 'correct' : 'incorrect';
|
|
};
|
|
|
|
// Calculate results
|
|
const correctCount = matches.filter(m => m.isCorrect).length;
|
|
const score = calculateScore(correctCount, attempts);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
|
|
<p className="text-gray-600 mt-1">{description}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
|
<Target size={16} />
|
|
<span className="font-semibold text-sm">{maxPoints} pts</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-blue-600 bg-blue-50 px-3 py-1.5 rounded-full">
|
|
<Zap size={16} />
|
|
<span className="font-semibold text-sm">{attempts} {attempts === 1 ? 'intento' : 'intentos'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Matching Area */}
|
|
<Card className="p-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
{/* Left Column */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
|
Columna A
|
|
</h4>
|
|
{displayLeftItems.map(item => {
|
|
const matchedItem = getMatchedRightItem(item.id);
|
|
const status = getMatchStatus(item.id);
|
|
const isSelected = selectedLeft === item.id;
|
|
const isMatched = isLeftMatched(item.id);
|
|
|
|
return (
|
|
<motion.div
|
|
key={item.id}
|
|
draggable={!showResults && !isMatched}
|
|
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
|
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
|
onDragLeave={() => handleDragLeave('left')}
|
|
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
|
onClick={() => handleLeftClick(item.id)}
|
|
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
|
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
|
className={`
|
|
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
|
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
|
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
|
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
|
${status === 'correct' ? 'border-green-500 bg-green-50' : ''}
|
|
${status === 'incorrect' ? 'border-red-500 bg-red-50' : ''}
|
|
${dragOverLeft === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
|
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
|
`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
|
<span className="font-medium text-gray-800">{item.content}</span>
|
|
</div>
|
|
|
|
{/* Match indicator */}
|
|
{matchedItem && (
|
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Link2 size={14} className="text-gray-400" />
|
|
<span className="text-gray-600">{matchedItem.content}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status icons */}
|
|
{showResults && status && (
|
|
<div className="absolute top-2 right-2">
|
|
{status === 'correct' ? (
|
|
<CheckCircle size={20} className="text-green-600" />
|
|
) : (
|
|
<XCircle size={20} className="text-red-600" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Remove button */}
|
|
{isMatched && !showResults && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveMatch(item.id);
|
|
}}
|
|
className="absolute top-2 right-2 p-1 hover:bg-gray-200 rounded-full transition-colors"
|
|
>
|
|
<XCircle size={16} className="text-gray-400 hover:text-red-500" />
|
|
</button>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Right Column */}
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
|
Columna B
|
|
</h4>
|
|
{displayRightItems.map(item => {
|
|
const matchedItem = getMatchedLeftItem(item.id);
|
|
const isSelected = selectedRight === item.id;
|
|
const isMatched = isRightMatched(item.id);
|
|
|
|
return (
|
|
<motion.div
|
|
key={item.id}
|
|
draggable={!showResults && !isMatched}
|
|
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
|
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
|
onDragLeave={() => handleDragLeave('right')}
|
|
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
|
onClick={() => handleRightClick(item.id)}
|
|
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
|
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
|
className={`
|
|
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
|
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
|
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
|
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
|
${dragOverRight === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
|
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
|
`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
|
<span className="font-medium text-gray-800">{item.content}</span>
|
|
</div>
|
|
|
|
{/* Match indicator */}
|
|
{matchedItem && (
|
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Link2 size={14} className="text-gray-400" />
|
|
<span className="text-gray-600">{matchedItem.content}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Results Section */}
|
|
<AnimatePresence>
|
|
{showResults && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
>
|
|
<Card className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200">
|
|
<div className="text-center mb-6">
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
|
className={`inline-flex items-center justify-center w-20 h-20 rounded-full mb-4 ${
|
|
correctCount === leftItems.length
|
|
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
|
: 'bg-gradient-to-br from-blue-400 to-indigo-500'
|
|
}`}
|
|
>
|
|
<Trophy size={40} className="text-white" />
|
|
</motion.div>
|
|
|
|
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
|
{correctCount === leftItems.length
|
|
? '¡Perfecto!'
|
|
: correctCount >= leftItems.length * 0.7
|
|
? '¡Muy bien!'
|
|
: '¡Sigue practicando!'}
|
|
</h3>
|
|
|
|
<p className="text-gray-600">
|
|
{correctCount} de {leftItems.length} emparejamientos correctos
|
|
</p>
|
|
</div>
|
|
|
|
{/* Score Display */}
|
|
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto mb-6">
|
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
|
<Star className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
|
<p className="text-2xl font-bold text-blue-700">{score}</p>
|
|
<p className="text-sm text-blue-600">Puntuación</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
|
<Target className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
|
<p className="text-2xl font-bold text-green-700">{maxPoints}</p>
|
|
<p className="text-sm text-green-600">Máximo</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
|
<Zap className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
|
<p className="text-2xl font-bold text-purple-700">
|
|
{Math.round((score / maxPoints) * 100)}%
|
|
</p>
|
|
<p className="text-sm text-purple-600">Precisión</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex justify-between items-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleReset}
|
|
>
|
|
<RefreshCcw size={16} className="mr-2" />
|
|
Reiniciar
|
|
</Button>
|
|
|
|
<div className="flex gap-3">
|
|
{!showResults ? (
|
|
<Button
|
|
onClick={handleValidate}
|
|
disabled={!allMatched}
|
|
>
|
|
Validar Emparejamientos
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handleReset}>
|
|
Intentar de Nuevo
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Instructions */}
|
|
{!allMatched && matches.length > 0 && !showResults && (
|
|
<div className="text-center text-sm text-gray-500">
|
|
<p>
|
|
Arrastra elementos o haz clic para conectar. Tienes{' '}
|
|
<span className="font-semibold text-blue-600">{leftItems.length - matches.length}</span>{' '}
|
|
emparejamientos pendientes.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{allMatched && !showResults && (
|
|
<div className="text-center text-sm text-green-600">
|
|
<p className="font-medium">Todos los elementos están emparejados. ¡Valida tu respuesta!</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default MatchingExercise;
|