README / components /arena /TaxonomyMatrix.tsx
kaigiii's picture
Deploy Learn8 Demo Space
5c920e9
"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>
);
}