Spaces:
Runtime error
Runtime error
| "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<string, string>; // 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<Record<string, string[]>>( | |
| () => { | |
| const init: Record<string, string[]> = {}; | |
| buckets.forEach((b) => (init[b] = [])); | |
| return init; | |
| } | |
| ); | |
| const [unclassified, setUnclassified] = useState<string[]>( | |
| items.map((i) => i.id) | |
| ); | |
| const [selectedItem, setSelectedItem] = useState<string | null>(null); | |
| const [wrongItems, setWrongItems] = useState<string[]>([]); | |
| const [completed, setCompleted] = useState(false); | |
| const [hintUsed, setHintUsed] = useState(false); | |
| const [hintItem, setHintItem] = useState<string | null>(null); | |
| const [errorMsg, setErrorMsg] = useState<string | null>(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<string, string[]> = {}; | |
| 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<string, string[]> = {}; | |
| 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<string, string[]> = {}; | |
| 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 ( | |
| <div className="flex-1 flex flex-col min-w-0"> | |
| {/* βββ Question card βββ */} | |
| <motion.div | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mt-4 mb-6 flex items-center gap-4" | |
| > | |
| <div className="flex-shrink-0 w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-md shadow-purple-300/30"> | |
| <span className="font-heading font-extrabold text-white text-base"> | |
| {stageIndex + 1} | |
| </span> | |
| </div> | |
| <div> | |
| <p className="text-[11px] font-bold text-indigo-500 uppercase tracking-wider mb-0.5"> | |
| Stage {stageIndex + 1} of {totalStages} Β· {topic} | |
| </p> | |
| <h2 className="font-heading font-bold text-xl text-brand-gray-700 leading-snug"> | |
| {description} | |
| </h2> | |
| </div> | |
| </motion.div> | |
| {/* βββ Instruction chip βββ */} | |
| <AnimatePresence> | |
| {!allPlaced && !completed && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -6 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="mb-4 flex items-center gap-2" | |
| > | |
| <span className="inline-flex items-center gap-1.5 text-xs font-bold text-brand-gray-500 bg-white/60 backdrop-blur border border-brand-gray-200 rounded-full px-3 py-1.5"> | |
| <span className="text-brand-teal">β </span> Tap an item below | |
| <span className="text-brand-teal mx-0.5">β</span> | |
| <span className="text-brand-teal">β‘</span> Tap a bucket to classify | |
| </span> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* βββ Bucket zones βββ */} | |
| <div className="grid grid-cols-2 gap-4 mb-5"> | |
| {buckets.map((bucket, bi) => { | |
| const accent = BUCKET_ACCENTS[bi % BUCKET_ACCENTS.length]; | |
| const bucketItems = assignments[bucket] || []; | |
| const isTarget = !!selectedItem; | |
| return ( | |
| <motion.div | |
| key={bucket} | |
| onClick={() => 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 */} | |
| <div className="flex items-center gap-2 mb-3"> | |
| <div | |
| className={`h-6 w-6 rounded-lg flex items-center justify-center text-xs font-bold text-white ${ | |
| bi === 0 | |
| ? "bg-gradient-to-br from-brand-teal to-[#5fb3af]" | |
| : "bg-gradient-to-br from-brand-coral to-[#d4654a]" | |
| }`} | |
| > | |
| {bi === 0 ? "β" : "β"} | |
| </div> | |
| <h4 className="font-heading font-bold text-brand-gray-700 text-sm"> | |
| {bucket} | |
| </h4> | |
| </div> | |
| {/* Items in this bucket */} | |
| <div className="flex flex-wrap gap-2"> | |
| <AnimatePresence mode="popLayout"> | |
| {bucketItems.map((itemId) => { | |
| const item = getItem(itemId); | |
| const isWrong = wrongItems.includes(itemId); | |
| const isHint = hintItem === itemId; | |
| return ( | |
| <motion.button | |
| key={itemId} | |
| layout | |
| initial={{ scale: 0.8, opacity: 0 }} | |
| animate={{ | |
| scale: 1, | |
| opacity: 1, | |
| x: isWrong ? [0, -6, 6, -6, 6, 0] : 0, | |
| }} | |
| exit={{ scale: 0.8, opacity: 0 }} | |
| transition={ | |
| isWrong | |
| ? { x: { duration: 0.5 } } | |
| : { type: "spring", damping: 18 } | |
| } | |
| onClick={(e) => 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 && ( | |
| <span className="ml-1.5 text-[10px] opacity-0 group-hover:opacity-60 transition"> | |
| Γ | |
| </span> | |
| )} | |
| {completed && ( | |
| <span className="ml-1.5">β</span> | |
| )} | |
| </motion.button> | |
| ); | |
| })} | |
| </AnimatePresence> | |
| </div> | |
| {/* Empty state */} | |
| {bucketItems.length === 0 && ( | |
| <div className="flex items-center justify-center h-16 text-xs text-brand-gray-300 italic"> | |
| {isTarget ? "β Tap to place here" : "No items yet"} | |
| </div> | |
| )} | |
| </motion.div> | |
| ); | |
| })} | |
| </div> | |
| {/* βββ Error message βββ */} | |
| <AnimatePresence> | |
| {errorMsg && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -8 }} | |
| className="mb-3 px-4 py-2.5 rounded-xl bg-red-50 border border-red-200 text-xs text-red-600 font-medium" | |
| > | |
| π‘ {errorMsg} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* βββ Unclassified items pool βββ */} | |
| <AnimatePresence mode="popLayout"> | |
| {unclassified.length > 0 && ( | |
| <motion.div layout> | |
| <p className="text-[10px] font-bold uppercase tracking-widest text-brand-gray-400 mb-2"> | |
| Classify these items β | |
| </p> | |
| <div className="flex flex-col gap-2.5"> | |
| {unclassified.map((itemId) => { | |
| const item = getItem(itemId); | |
| const isSelected = selectedItem === itemId; | |
| const isHint = hintItem === itemId; | |
| return ( | |
| <motion.button | |
| key={itemId} | |
| layout | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: 20, height: 0 }} | |
| transition={{ type: "spring", damping: 20 }} | |
| onClick={() => 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" | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <span | |
| className={`flex-shrink-0 h-7 w-7 rounded-lg flex items-center justify-center text-xs font-bold transition ${ | |
| isSelected | |
| ? "bg-brand-teal text-white" | |
| : "bg-brand-gray-100 text-brand-gray-400" | |
| }`} | |
| > | |
| {isSelected ? "β" : "?"} | |
| </span> | |
| <span className="text-sm">{item.content}</span> | |
| </div> | |
| </motion.button> | |
| ); | |
| })} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* βββ All placed message βββ */} | |
| {allPlaced && !completed && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mt-3 text-center text-sm text-brand-teal font-semibold" | |
| > | |
| All items classified! Tap CHECK to verify. | |
| </motion.div> | |
| )} | |
| {/* Spacer */} | |
| <div className="flex-1 min-h-[40px]" /> | |
| {/* βββ Bottom Bar βββ */} | |
| <div className="relative pt-4 pb-6 flex items-end justify-between"> | |
| {/* Mascot + hint */} | |
| <div className="flex items-end gap-2"> | |
| <OwlMascotSmall /> | |
| <button | |
| onClick={handleHint} | |
| disabled={hintUsed || completed || unclassified.length === 0} | |
| className={`mb-1 flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full border transition ${ | |
| hintUsed | |
| ? "bg-brand-gray-100 text-brand-gray-400 border-brand-gray-200 cursor-not-allowed" | |
| : "bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100" | |
| }`} | |
| > | |
| π‘ Hint | |
| <span className="text-[10px] opacity-60">(10 π)</span> | |
| </button> | |
| </div> | |
| {/* CHECK button */} | |
| <GameButton | |
| variant="primary" | |
| onClick={handleCheck} | |
| disabled={!allPlaced || completed} | |
| className="min-w-[140px]" | |
| > | |
| CHECK | |
| </GameButton> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /* βββββββββββββββββββ Owl Mascot (compact) βββββββββββββββββββ */ | |
| function OwlMascotSmall() { | |
| return ( | |
| <svg viewBox="0 0 80 80" className="h-14 w-14 flex-shrink-0" fill="none"> | |
| <ellipse cx="40" cy="52" rx="22" ry="20" fill="#E8A855" /> | |
| <ellipse cx="40" cy="54" rx="16" ry="14" fill="#F5DEB3" /> | |
| <circle cx="32" cy="40" r="9" fill="white" /> | |
| <circle cx="48" cy="40" r="9" fill="white" /> | |
| <circle cx="33" cy="40" r="5" fill="#2D2D2D" /> | |
| <circle cx="47" cy="40" r="5" fill="#2D2D2D" /> | |
| <circle cx="34.5" cy="38.5" r="1.8" fill="white" /> | |
| <circle cx="48.5" cy="38.5" r="1.8" fill="white" /> | |
| <polygon points="40,44 37,48 43,48" fill="#E8734A" /> | |
| <polygon points="22,30 26,38 18,36" fill="#D4943D" /> | |
| <polygon points="58,30 54,38 62,36" fill="#D4943D" /> | |
| <ellipse cx="33" cy="72" rx="5" ry="3" fill="#E8734A" /> | |
| <ellipse cx="47" cy="72" rx="5" ry="3" fill="#E8734A" /> | |
| </svg> | |
| ); | |
| } | |