"use client"; import React, { useState, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import GameButton from "@/components/ui/GameButton"; /* ═══════════════════ Types ═══════════════════ */ export interface TaxonomyItem { id: string; content: string; } export interface TaxonomyMatrixProps { stageIndex: number; totalStages: number; topic: string; description: string; buckets: string[]; items: TaxonomyItem[]; correctAssignments: Record; // itemId → bucket name feedbackMsg: { success: string; error: string; hint: string }; onComplete: () => void; onError?: () => void; onHintUse: () => boolean; // returns false if can't afford } /* ═══════════════════ Bucket accent colours ═══════════════════ */ const BUCKET_ACCENTS = [ { border: "border-brand-teal", borderDash: "border-brand-teal/40", bg: "bg-brand-teal/5", bgHover: "hover:bg-brand-teal/10", text: "text-teal-700", tagBg: "bg-brand-teal/10", tagBorder: "border-brand-teal/40", icon: "✓", }, { border: "border-brand-coral", borderDash: "border-brand-coral/40", bg: "bg-brand-coral/5", bgHover: "hover:bg-brand-coral/10", text: "text-red-700", tagBg: "bg-brand-coral/10", tagBorder: "border-brand-coral/40", icon: "✗", }, ]; /* ═══════════════════ Component ═══════════════════ */ export default function TaxonomyMatrix({ stageIndex, totalStages, topic, description, buckets, items, correctAssignments, feedbackMsg, onComplete, onError, onHintUse, }: TaxonomyMatrixProps) { /* ── State ── */ const [assignments, setAssignments] = useState>( () => { const init: Record = {}; buckets.forEach((b) => (init[b] = [])); return init; } ); const [unclassified, setUnclassified] = useState( items.map((i) => i.id) ); const [selectedItem, setSelectedItem] = useState(null); const [wrongItems, setWrongItems] = useState([]); const [completed, setCompleted] = useState(false); const [hintUsed, setHintUsed] = useState(false); const [hintItem, setHintItem] = useState(null); const [errorMsg, setErrorMsg] = useState(null); const allPlaced = unclassified.length === 0; const getItem = useCallback( (id: string) => items.find((i) => i.id === id)!, [items] ); /* ── Select item from pool ── */ const selectItem = useCallback( (id: string) => { if (completed) return; setSelectedItem((prev) => (prev === id ? null : id)); setWrongItems([]); setErrorMsg(null); }, [completed] ); /* ── Place selected item into a bucket ── */ const placeInBucket = useCallback( (bucket: string) => { if (!selectedItem || completed) return; setUnclassified((prev) => prev.filter((id) => id !== selectedItem)); setAssignments((prev) => { const next: Record = {}; for (const b of Object.keys(prev)) { next[b] = prev[b].filter((id) => id !== selectedItem); } next[bucket] = [...(next[bucket] || []), selectedItem]; return next; }); setSelectedItem(null); }, [selectedItem, completed] ); /* ── Remove item from bucket back to pool ── */ const removeFromBucket = useCallback( (itemId: string, e: React.MouseEvent) => { e.stopPropagation(); if (completed) return; setAssignments((prev) => { const next: Record = {}; for (const b of Object.keys(prev)) { next[b] = prev[b].filter((id) => id !== itemId); } return next; }); setUnclassified((prev) => [...prev, itemId]); }, [completed] ); /* ── Check all assignments ── */ const handleCheck = useCallback(() => { if (!allPlaced || completed) return; const wrong: string[] = []; for (const [bucket, itemIds] of Object.entries(assignments)) { for (const id of itemIds) { if (correctAssignments[id] !== bucket) wrong.push(id); } } if (wrong.length === 0) { setCompleted(true); onComplete(); } else { setWrongItems(wrong); setErrorMsg(feedbackMsg.error); onError?.(); setTimeout(() => { setAssignments((prev) => { const next: Record = {}; for (const b of Object.keys(prev)) { next[b] = prev[b].filter((id) => !wrong.includes(id)); } return next; }); setUnclassified((prev) => [...prev, ...wrong]); setWrongItems([]); }, 900); } }, [allPlaced, completed, assignments, correctAssignments, onComplete, feedbackMsg]); /* ── Hint ── */ const handleHint = useCallback(() => { if (hintUsed || completed || unclassified.length === 0) return; if (!onHintUse()) return; setHintUsed(true); const itemId = unclassified[0]; const correctBucket = correctAssignments[itemId]; setHintItem(itemId); setSelectedItem(itemId); setTimeout(() => { setUnclassified((prev) => prev.filter((id) => id !== itemId)); setAssignments((prev) => { const next = { ...prev }; next[correctBucket] = [...next[correctBucket], itemId]; return next; }); setSelectedItem(null); setHintItem(null); }, 800); }, [hintUsed, completed, onHintUse, unclassified, correctAssignments]); /* ═══════════════════ Render ═══════════════════ */ return (
{/* ─── Question card ─── */}
{stageIndex + 1}

Stage {stageIndex + 1} of {totalStages} · {topic}

{description}

{/* ─── Instruction chip ─── */} {!allPlaced && !completed && ( Tap an item below Tap a bucket to classify )} {/* ─── Bucket zones ─── */}
{buckets.map((bucket, bi) => { const accent = BUCKET_ACCENTS[bi % BUCKET_ACCENTS.length]; const bucketItems = assignments[bucket] || []; const isTarget = !!selectedItem; return ( placeInBucket(bucket)} whileHover={isTarget ? { scale: 1.02 } : {}} whileTap={isTarget ? { scale: 0.98 } : {}} className={`relative rounded-2xl border-2 border-dashed p-4 min-h-[160px] transition-all cursor-pointer ${ isTarget ? `${accent.borderDash} ${accent.bg} ${accent.bgHover} shadow-md` : "border-brand-gray-200 bg-white/40 backdrop-blur-sm" } ${completed ? "opacity-80" : ""}`} > {/* Bucket label */}
{bi === 0 ? "✓" : "✗"}

{bucket}

{/* Items in this bucket */}
{bucketItems.map((itemId) => { const item = getItem(itemId); const isWrong = wrongItems.includes(itemId); const isHint = hintItem === itemId; return ( removeFromBucket(itemId, e)} className={`px-3 py-2 rounded-xl text-xs font-semibold border transition group ${ completed ? "bg-brand-green/10 text-brand-green border-brand-green/40" : isWrong ? "bg-red-50 text-red-500 border-red-400" : isHint ? "bg-amber-50 text-amber-600 border-amber-300" : `${accent.tagBg} ${accent.text} ${accent.tagBorder}` }`} > {item.content} {!completed && ( × )} {completed && ( )} ); })}
{/* Empty state */} {bucketItems.length === 0 && (
{isTarget ? "← Tap to place here" : "No items yet"}
)}
); })}
{/* ─── Error message ─── */} {errorMsg && ( 💡 {errorMsg} )} {/* ─── Unclassified items pool ─── */} {unclassified.length > 0 && (

Classify these items ↑

{unclassified.map((itemId) => { const item = getItem(itemId); const isSelected = selectedItem === itemId; const isHint = hintItem === itemId; return ( selectItem(itemId)} whileTap={{ scale: 0.97 }} className={`w-full text-left rounded-2xl px-5 py-4 border-2 border-b-4 font-heading font-bold text-base transition-all ${ isHint ? "border-amber-400 bg-amber-50 text-amber-700 shadow-md shadow-amber-200/40" : isSelected ? "border-brand-teal bg-brand-teal/10 text-brand-teal shadow-md shadow-teal-200/40" : "border-brand-gray-200 bg-white text-brand-gray-700 hover:border-brand-teal/40 hover:shadow-sm" }`} >
{isSelected ? "↑" : "?"} {item.content}
); })}
)}
{/* ─── All placed message ─── */} {allPlaced && !completed && ( All items classified! Tap CHECK to verify. )} {/* Spacer */}
{/* ─── Bottom Bar ─── */}
{/* Mascot + hint */}
{/* CHECK button */} CHECK
); } /* ═══════════════════ Owl Mascot (compact) ═══════════════════ */ function OwlMascotSmall() { return ( ); }