Neuste Version

This commit is contained in:
MarcWieland
2026-06-19 16:28:08 +02:00
parent c25260fb5e
commit 13789fab1d
623 changed files with 65768 additions and 63 deletions
+9
View File
@@ -21,6 +21,15 @@ function App() {
}, []);
const handleModeSelect = (newMode) => {
const savedMode = sessionStorage.getItem("mode");
if (savedMode !== newMode) {
sessionStorage.removeItem("quiz_questions");
sessionStorage.removeItem("quiz_current_idx");
sessionStorage.removeItem("quiz_score");
sessionStorage.removeItem("quiz_wrong_questions");
sessionStorage.removeItem("quiz_answer_status");
sessionStorage.removeItem("quiz_processed");
}
sessionStorage.setItem("mode", newMode);
setMode(newMode);
};
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
const COLORS = [
"#f43f5e", "#ec4899", "#d946ef", "#a855f7", "#8b5cf6",
"#6366f1", "#3b82f6", "#0ea5e9", "#06b6d4", "#14b8a6",
"#10b981", "#22c55e", "#84cc16", "#eab308", "#f97316"
];
const SHAPES = ["circle", "square", "triangle"];
export default function Confetti({ count = 80 }) {
const [particles, setParticles] = useState([]);
useEffect(() => {
const generated = Array.from({ length: count }).map((_, idx) => {
const size = Math.random() * 8 + 6; // 6px - 14px
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
const left = Math.random() * 100; // 0% - 100%
const delay = Math.random() * 4; // 0s - 4s delay
const duration = Math.random() * 3 + 3; // 3s - 6s duration
const rotation = Math.random() * 360;
return {
id: idx,
size,
color,
shape,
left: `${left}%`,
delay: `${delay}s`,
duration: `${duration}s`,
rotation: `${rotation}deg`
};
});
setParticles(generated);
}, [count]);
return (
<>
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(105vh) rotate(720deg);
opacity: 0.3;
}
}
@keyframes confetti-sway {
0%, 100% {
transform: translateX(0px);
}
50% {
transform: translateX(50px);
}
}
.confetti-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.confetti-particle {
position: absolute;
top: -20px;
animation-name: confetti-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.confetti-swayer {
animation: confetti-sway 3s ease-in-out infinite alternate;
}
`}</style>
<div className="confetti-container">
{particles.map((p) => {
let style = {
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.shape !== "triangle" ? p.color : "transparent",
left: p.left,
animationDelay: p.delay,
animationDuration: p.duration,
transform: `rotate(${p.rotation})`,
};
if (p.shape === "circle") {
style.borderRadius = "50%";
} else if (p.shape === "triangle") {
style.width = "0";
style.height = "0";
style.borderLeft = `${p.size / 2}px solid transparent`;
style.borderRight = `${p.size / 2}px solid transparent`;
style.borderBottom = `${p.size}px solid ${p.color}`;
}
return (
<div
key={p.id}
className="confetti-particle"
style={style}
>
<div className="confetti-swayer" style={{ width: "100%", height: "100%" }} />
</div>
);
})}
</div>
</>
);
}
+11 -3
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
export default function QuestionCard({ frage, antworten, onNext, isLast }) {
export default function QuestionCard({ frage, antworten, onNext, onCheck, isLast }) {
const [selected, setSelected] = useState([]);
const [checked, setChecked] = useState(false); // wurde „Überprüfen“ gedrückt
@@ -17,7 +17,15 @@ export default function QuestionCard({ frage, antworten, onNext, isLast }) {
const isCorrect = (idx) => antworten[idx].korrekt;
const wasSelected = (idx) => selected.includes(idx);
const handleCheck = () => setChecked(true);
const handleCheck = () => {
setChecked(true);
const correct = antworten.every((antwort, idx) => {
return antwort.korrekt === selected.includes(idx);
});
if (onCheck) {
onCheck(correct);
}
};
const handleNext = () => {
setSelected([]);
@@ -26,7 +34,7 @@ export default function QuestionCard({ frage, antworten, onNext, isLast }) {
};
return (
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded-xl shadow-lg transition transform hover:-translate-y-1 hover:shadow-xl">
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded-xl shadow-lg transition transform hover:-translate-y-1 hover:shadow-xl animate-float-in">
<h2 className="text-lg font-semibold mb-4">{frage}</h2>
<div className="space-y-2">
{antworten.map((antwort, idx) => {
+15
View File
@@ -3,3 +3,18 @@
body {
@apply bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
}
@keyframes float-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-float-in {
animation: float-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
+260 -17
View File
@@ -1,23 +1,266 @@
import { useEffect, useState } from "react";
import {
getXP,
getLevelProgress,
getStreak,
getUnlockedAchievements,
getHighscores,
ACHIEVEMENTS_LIST
} from "../utils/gamification";
export default function ModeSelectPage({ onSelect }) {
const [xp, setXp] = useState(0);
const [streak, setStreak] = useState(0);
const [unlocked, setUnlocked] = useState([]);
const [highscores, setHighscores] = useState([]);
const [activeTab, setActiveTab] = useState("achievements"); // "achievements" | "highscores"
useEffect(() => {
setXp(getXP());
setStreak(getStreak());
setUnlocked(getUnlockedAchievements());
setHighscores(getHighscores());
}, []);
const progress = getLevelProgress(xp);
const formatTime = (secs) => {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s < 10 ? "0" : ""}${s}`;
};
return (
<div className="h-screen flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded shadow-md w-full max-w-md text-center">
<h1 className="text-2xl font-bold mb-4">Wähle deinen Modus</h1>
<p className="mb-6 text-gray-700 dark:text-gray-300">D- oder C-Lizenz?</p>
<div className="flex flex-col gap-4 sm:flex-row justify-center">
<button
onClick={() => onSelect("d")}
className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-6 rounded transition"
>
D-Lizenz
</button>
<button
onClick={() => onSelect("c")}
className="bg-green-600 hover:bg-green-700 text-white py-2 px-6 rounded transition"
>
C-Lizenz
</button>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 py-8 px-4 sm:px-6">
<div className="max-w-5xl mx-auto space-y-6">
{/* Header mit Titel und Streak */}
<header className="flex flex-col sm:flex-row justify-between items-center bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 gap-4 transition-all">
<div className="text-center sm:text-left">
<h1 className="text-3xl font-extrabold bg-gradient-to-r from-blue-600 to-indigo-500 dark:from-blue-400 dark:to-indigo-300 bg-clip-text text-transparent">
Schiri-Trainer Volleyball
</h1>
</div>
{/* Streak Flame */}
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900/50 rounded-full shadow-inner animate-pulse">
<span className="text-2xl">🔥</span>
<div className="text-left">
<p className="text-xs text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wider">Lernserie</p>
<p className="text-lg font-bold text-amber-800 dark:text-amber-300">
{streak} {streak === 1 ? "Tag" : "Tage"}
</p>
</div>
</div>
</header>
{/* Dashboard Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* LINKE SPALTE: Profil, Modusauswahl */}
<div className="md:col-span-2 space-y-6">
{/* Level & XP Card */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-center mb-3">
<div>
<h2 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Erfahrungsstufe</h2>
<p className="text-3xl font-black text-blue-600 dark:text-blue-400">Level {progress.currentLevel}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-400 dark:text-gray-500">Gesamt-XP</p>
<p className="text-lg font-extrabold">{xp} XP</p>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-1">
<div className="w-full bg-gray-100 dark:bg-gray-700 h-4 rounded-full overflow-hidden p-[2px] border border-gray-200/50 dark:border-gray-600/50">
<div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 dark:from-blue-600 dark:to-indigo-500 rounded-full transition-all duration-1000 ease-out"
style={{ width: `${progress.percentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 font-medium">
<span>{progress.xpCurrentLevelStart} XP</span>
<span>{progress.percentage}% zum nächsten Level</span>
<span>{progress.xpNextLevelStart} XP</span>
</div>
</div>
</section>
{/* Modusauswahl Card */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<h2 className="text-xl font-bold mb-4">Starte ein neues Quiz</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* D-Lizenz */}
<button
onClick={() => onSelect("d")}
className="group relative flex flex-col items-center justify-center p-6 bg-gradient-to-br from-blue-50 to-blue-100/30 dark:from-blue-950/20 dark:to-blue-900/10 border border-blue-200/60 dark:border-blue-800/40 rounded-2xl shadow-sm hover:shadow-md hover:border-blue-400 dark:hover:border-blue-600 text-center transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
>
<span className="text-4xl mb-3 group-hover:scale-110 transition-transform">🥉</span>
<h3 className="text-lg font-bold text-blue-900 dark:text-blue-300">D-Lizenz</h3>
<p className="text-xs text-blue-700/70 dark:text-blue-400/70 mt-1 max-w-[200px]">
Grundlegende Spielregeln und Standard-Schiedsrichterentscheidungen.
</p>
<span className="mt-4 px-4 py-1.5 bg-blue-600 group-hover:bg-blue-700 text-white font-semibold text-xs rounded-full shadow-sm group-hover:shadow transition-all">
Jetzt lernen
</span>
</button>
{/* C-Lizenz */}
<button
onClick={() => onSelect("c")}
className="group relative flex flex-col items-center justify-center p-6 bg-gradient-to-br from-emerald-50 to-emerald-100/30 dark:from-emerald-950/20 dark:to-emerald-900/10 border border-emerald-200/60 dark:border-emerald-800/40 rounded-2xl shadow-sm hover:shadow-md hover:border-emerald-400 dark:hover:border-emerald-600 text-center transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
>
<span className="text-4xl mb-3 group-hover:scale-110 transition-transform">🥈</span>
<h3 className="text-lg font-bold text-emerald-900 dark:text-emerald-300">C-Lizenz</h3>
<p className="text-xs text-emerald-700/70 dark:text-emerald-400/70 mt-1 max-w-[200px]">
Fortgeschrittene Regelkunde, Spezialfälle und komplexe Situationen.
</p>
<span className="mt-4 px-4 py-1.5 bg-emerald-600 group-hover:bg-emerald-700 text-white font-semibold text-xs rounded-full shadow-sm group-hover:shadow transition-all">
Jetzt lernen
</span>
</button>
</div>
</section>
</div>
{/* RECHTE SPALTE: Achievements und Highscores */}
<div className="space-y-6">
{/* Tabs Controller */}
<div className="bg-white dark:bg-gray-800 p-2 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 flex gap-2">
<button
onClick={() => setActiveTab("achievements")}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all cursor-pointer ${activeTab === "achievements"
? "bg-blue-600 text-white shadow-sm"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
>
Erfolge
</button>
<button
onClick={() => setActiveTab("highscores")}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all cursor-pointer ${activeTab === "highscores"
? "bg-blue-600 text-white shadow-sm"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
>
Bestenliste
</button>
</div>
{/* Content Card */}
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 min-h-[300px]">
{/* Tab: Achievements */}
{activeTab === "achievements" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-bold text-lg">Erfolge</h3>
<span className="text-xs bg-gray-100 dark:bg-gray-700 px-2.5 py-1 rounded-full font-semibold">
{unlocked.length} / {ACHIEVEMENTS_LIST.length}
</span>
</div>
<div className="space-y-3 max-h-[350px] overflow-y-auto pr-1">
{ACHIEVEMENTS_LIST.map((ach) => {
const isUnlocked = unlocked.includes(ach.id);
return (
<div
key={ach.id}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${isUnlocked
? "bg-blue-50/30 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/40"
: "bg-gray-50/50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/30 opacity-60"
}`}
>
<div className={`text-2xl p-1.5 rounded-lg ${isUnlocked ? "bg-white dark:bg-gray-800 shadow-sm" : ""
}`}>
{ach.title.split(" ").slice(-1)[0]} {/* Nur Emoji */}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-bold truncate ${isUnlocked ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400"
}`}>
{ach.title.split(" ").slice(0, -1).join(" ")}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 line-clamp-2 leading-tight">
{ach.description}
</p>
</div>
<div className="text-right shrink-0">
<span className="text-xs font-bold text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40 px-2 py-0.5 rounded">
+{ach.xpBonus} XP
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Tab: Highscores */}
{activeTab === "highscores" && (
<div className="space-y-4">
<h3 className="font-bold text-lg">Top 5 Versuche</h3>
{highscores.length === 0 ? (
<div className="text-center py-12 text-gray-400 dark:text-gray-500">
<span className="text-4xl block mb-2">🏆</span>
<p className="text-sm">Noch keine Einträge vorhanden.</p>
<p className="text-xs">Beende dein erstes Quiz, um hier gelistet zu werden!</p>
</div>
) : (
<div className="space-y-2">
{highscores.map((hs, idx) => (
<div
key={idx}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-gray-100 dark:border-gray-800"
>
<div className="flex items-center gap-3">
<span className={`w-6 h-6 flex items-center justify-center font-black rounded-full text-xs ${idx === 0
? "bg-yellow-400 text-yellow-950"
: idx === 1
? "bg-slate-300 text-slate-800"
: idx === 2
? "bg-amber-600 text-amber-50"
: "bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
}`}>
{idx + 1}
</span>
<div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm">{hs.score}/{hs.total}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.2 rounded-full ${hs.mode === "D"
? "bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400"
: "bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400"
}`}>
{hs.mode}
</span>
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">{hs.date}</span>
</div>
</div>
<div className="text-right">
<p className="font-extrabold text-sm text-gray-700 dark:text-gray-300">{hs.percentage}%</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500"> {formatTime(hs.time)}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
+23
View File
@@ -11,6 +11,10 @@ export default function QuizPage({ mode, onBack, customQuestions }) {
const [showResult, setShowResult] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [answerStatus, setAnswerStatus] = useState([]); // "correct" | "wrong" | undefined
const [startTime, setStartTime] = useState(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [isRepeatRun, setIsRepeatRun] = useState(false);
const navigate = useNavigate();
@@ -46,6 +50,12 @@ useEffect(() => {
}
}, [mode, customQuestions, refreshKey]);
useEffect(() => {
if (questions.length > 0 && !startTime) {
setStartTime(Date.now());
}
}, [questions, startTime]);
const handleAnswer = (isCorrect, currentQuestion) => {
const index = currentIdx;
@@ -78,6 +88,8 @@ useEffect(() => {
setCurrentIdx(nextIndex);
sessionStorage.setItem("quiz_current_idx", nextIndex);
} else {
const duration = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0;
setElapsedTime(duration);
setShowResult(true);
}
};
@@ -88,6 +100,7 @@ useEffect(() => {
sessionStorage.removeItem("quiz_score");
sessionStorage.removeItem("quiz_wrong_questions");
sessionStorage.removeItem("quiz_answer_status");
sessionStorage.removeItem("quiz_processed");
};
if (showResult) {
@@ -96,6 +109,9 @@ useEffect(() => {
score={score}
total={questions.length}
wrongQuestions={wrongQuestions}
timeInSeconds={elapsedTime}
mode={mode}
isRepeatRun={isRepeatRun}
onRestart={() => {
handleReset();
setScore(0);
@@ -104,6 +120,9 @@ useEffect(() => {
setShowResult(false);
setQuestions([]);
setAnswerStatus([]);
setStartTime(null);
setElapsedTime(0);
setIsRepeatRun(false);
setRefreshKey((prev) => prev + 1);
}}
onRepeatWrong={() => {
@@ -114,6 +133,9 @@ useEffect(() => {
setQuestions(wrongQuestions);
setAnswerStatus([]);
setWrongQuestions([]);
setStartTime(null);
setElapsedTime(0);
setIsRepeatRun(true);
}}
onBack={onBack}
/>
@@ -165,6 +187,7 @@ useEffect(() => {
</div>
<QuestionCard
key={currentIdx}
frage={currentQuestion.frage}
antworten={currentQuestion.antworten}
onNext={handleNext}
+196 -43
View File
@@ -1,67 +1,220 @@
import { useEffect, useState } from "react";
import { addXP, updateStreak, saveHighscore, checkAchievements } from "../utils/gamification";
import Confetti from "../components/Confetti";
export default function ResultPage({
score,
total,
wrongQuestions,
timeInSeconds,
mode,
isRepeatRun,
onRestart,
onRepeatWrong,
onBack,
}) {
const wrongFromSession = JSON.parse(sessionStorage.getItem("quiz_wrong_questions")) || [];
const correctCount = total - wrongFromSession.length;
const [xpGained, setXpGained] = useState(0);
const [newAchievements, setNewAchievements] = useState([]);
const [isPersonalBest, setIsPersonalBest] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
useEffect(() => {
const processed = sessionStorage.getItem("quiz_processed") === "true";
if (processed) {
// Wenn bereits verarbeitet, berechne nur geschätzte XP für die Anzeige
let estXp = score * 10;
if (total >= 30 && !isRepeatRun) {
estXp += 100;
if (score === total) estXp += 250;
}
setXpGained(estXp);
return;
}
// 1. Streak aktualisieren
updateStreak();
// 2. Highscore speichern (nur wenn kein reines Wiederholungstraining)
let personalBest = false;
if (!isRepeatRun) {
const hsRes = saveHighscore(score, total, timeInSeconds, mode);
personalBest = hsRes.isNewPersonalBest;
setIsPersonalBest(personalBest);
}
// 3. XP berechnen
let baseXp = score * 10;
let completionBonus = 0;
let perfectBonus = 0;
if (total >= 30 && !isRepeatRun) {
completionBonus = 100;
if (score === total) {
perfectBonus = 250;
}
}
const initialGained = baseXp + completionBonus + perfectBonus;
addXP(initialGained);
// 4. Achievements checken
const unlocked = checkAchievements({
score,
total,
timeInSeconds,
mode,
isRepeatRun
});
setNewAchievements(unlocked.map((u) => u.achievement));
const totalXpGained = initialGained + unlocked.reduce((sum, u) => sum + u.achievement.xpBonus, 0);
setXpGained(totalXpGained);
if (score === total || unlocked.length > 0) {
setShowConfetti(true);
}
sessionStorage.setItem("quiz_processed", "true");
}, [score, total, timeInSeconds, mode, isRepeatRun]);
const percentage = Math.round((score / total) * 100);
const formatTime = (secs) => {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s < 10 ? "0" : ""}${s}`;
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded shadow-md max-w-md w-full text-center">
<h1 className="text-2xl font-bold mb-4">Auswertung</h1>
<p className="text-lg mb-6">
Du hast <strong>{correctCount}</strong> von <strong>{total}</strong> Fragen korrekt beantwortet.
</p>
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 py-10 px-4">
{showConfetti && <Confetti />}
{wrongFromSession.length > 0 ? (
<>
<p className="mb-4 text-gray-600 dark:text-gray-300">
Du hast <strong>{wrongFromSession.length}</strong> Frage(n) falsch beantwortet.
Möchtest du sie nochmal versuchen?
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-8 rounded-3xl shadow-xl border border-gray-100 dark:border-gray-700 max-w-lg w-full text-center space-y-6 transform transition-all duration-500 scale-100 animate-float-in">
{/* Titel und Trophäe */}
<header className="space-y-2">
{score === total ? (
<span className="text-6xl animate-bounce inline-block">🏆</span>
) : percentage >= 80 ? (
<span className="text-6xl inline-block">🥈</span>
) : (
<span className="text-6xl inline-block">📝</span>
)}
<h1 className="text-3xl font-black bg-gradient-to-r from-blue-600 to-indigo-500 dark:from-blue-400 dark:to-indigo-300 bg-clip-text text-transparent">
{score === total ? "Hervorragend!" : percentage >= 80 ? "Bestanden!" : "Gelernt!"}
</h1>
<p className="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500 font-bold">
{isRepeatRun ? "Wiederholungs-Modus" : `Modus: ${mode.toUpperCase()}-Lizenz`}
</p>
</header>
{/* Ergebnisse & Progress Ring */}
<section className="relative flex flex-col items-center py-4">
<div className="relative w-32 h-32 flex items-center justify-center rounded-full border-8 border-gray-100 dark:border-gray-700 shadow-inner">
<div
className={`absolute inset-0 rounded-full border-8 border-transparent transition-all duration-1000`}
style={{
borderTopColor: percentage >= 80 ? "#10b981" : "#3b82f6",
borderRightColor: percentage >= 90 ? "#10b981" : "transparent",
transform: `rotate(${Math.min(360, (percentage / 100) * 360)}deg)`
}}
/>
<div className="text-center">
<span className="text-3xl font-black">{percentage}%</span>
<p className="text-[10px] text-gray-400 dark:text-gray-500 font-bold uppercase tracking-wide">Richtig</p>
</div>
</div>
<p className="text-lg font-bold mt-4">
Du hast <span className="text-blue-600 dark:text-blue-400">{score}</span> von <span className="font-extrabold">{total}</span> Fragen richtig beantwortet.
</p>
</section>
{/* Gamification Stats Card */}
<section className="bg-gray-50 dark:bg-gray-900/50 rounded-2xl p-4 border border-gray-100 dark:border-gray-800 grid grid-cols-2 gap-4 text-left">
<div>
<p className="text-xs text-gray-400 dark:text-gray-500 font-medium"> Belohnung</p>
<p className="text-lg font-black text-emerald-600 dark:text-emerald-400">
+{xpGained} XP
</p>
</div>
<div>
<p className="text-xs text-gray-400 dark:text-gray-500 font-medium"> Zeit</p>
<p className="text-lg font-black">{formatTime(timeInSeconds)}</p>
</div>
{isPersonalBest && (
<div className="col-span-2 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900/40 rounded-xl p-2 flex items-center justify-center gap-2 text-yellow-700 dark:text-yellow-400 text-xs font-bold animate-pulse">
<span>👑</span> Neue persönliche Bestleistung!
</div>
)}
</section>
{/* Neue Achievements */}
{newAchievements.length > 0 && (
<section className="space-y-2 text-left">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Erfolge freigeschaltet! 🎉</h2>
<div className="space-y-2">
{newAchievements.map((ach) => (
<div
key={ach.id}
className="flex items-center gap-3 p-3 bg-gradient-to-r from-amber-50 to-orange-50/50 dark:from-amber-950/20 dark:to-orange-950/10 border border-amber-200 dark:border-amber-900/40 rounded-xl animate-pulse"
>
<span className="text-2xl">{ach.title.split(" ").slice(-1)[0]}</span>
<div className="flex-1">
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
{ach.title.split(" ").slice(0, -1).join(" ")}
</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{ach.description}</p>
</div>
<span className="text-xs font-black text-amber-700 dark:text-amber-400">
+{ach.xpBonus} XP
</span>
</div>
))}
</div>
</section>
)}
{/* Falsche Fragen & Navigation */}
<section className="space-y-3 pt-2">
{wrongQuestions.length > 0 ? (
<div className="flex flex-col gap-3">
<button
onClick={onRepeatWrong}
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded"
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
>
Falsche Fragen wiederholen
Falsche Fragen wiederholen ({wrongQuestions.length})
</button>
<button
onClick={onRestart}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
>
Neuer Durchlauf mit 30 Fragen
</button>
<button
onClick={onBack}
className="text-sm text-blue-600 underline mt-2"
>
Zurück zur Modusauswahl
Neues Quiz starten (30 Fragen)
</button>
</div>
</>
) : (
<>
<p className="mb-4 text-green-600 font-semibold">
Mega! Du hast alle Fragen korrekt beantwortet!
</p>
<button
onClick={onRestart}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Nochmals testen mit neuen Fragen
</button>
<button
onClick={onBack}
className="text-sm text-blue-600 underline mt-2"
>
Zurück zur Modusauswahl
</button>
</>
)}
) : (
<div className="space-y-3">
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/20 py-2.5 px-4 rounded-xl border border-emerald-200 dark:border-emerald-900/30">
Perfekt! Du hast alle Fragen richtig beantwortet! 🥳
</p>
<button
onClick={onRestart}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
>
Erneut testen (neue Fragen)
</button>
</div>
)}
<button
onClick={onBack}
className="text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:underline transition cursor-pointer mt-2 block mx-auto"
>
Zurück zur Startseite
</button>
</section>
</div>
</div>
);
+277
View File
@@ -0,0 +1,277 @@
// Gamification Utilities für SchiriTrainer
// Speichert alle Spieldaten lokal im localStorage
const KEY_XP = "schiri_xp";
const KEY_STREAK = "schiri_streak";
const KEY_STREAK_LAST_DATE = "schiri_streak_last_date";
const KEY_ACHIEVEMENTS = "schiri_achievements";
const KEY_HIGHSCORES = "schiri_highscores";
export const ACHIEVEMENTS_LIST = [
{
id: "first_quiz",
title: "Erster Pfiff 🎺",
description: "Beende dein erstes Quiz.",
xpBonus: 100,
},
{
id: "perfect_score",
title: "Fehlerfrei 🎯",
description: "Erreiche 30/30 Punkte in einem Quiz.",
xpBonus: 250,
},
{
id: "turboschiri",
title: "Turboschiri ⚡",
description: "Beende ein Quiz (30 Fragen) in unter 2 Minuten.",
xpBonus: 150,
},
{
id: "d_master",
title: "D-Lizenz Meister 🥉",
description: "D-Lizenz Quiz mit mindestens 24 Punkten bestanden.",
xpBonus: 100,
},
{
id: "c_master",
title: "C-Lizenz Meister 🥈",
description: "C-Lizenz Quiz mit mindestens 24 Punkten bestanden.",
xpBonus: 150,
},
{
id: "repeat_master",
title: "Wiederholungstäter 🔄",
description: "Falsche Fragen wiederholt und alle korrekt beantwortet.",
xpBonus: 100,
},
];
// --- XP & LEVEL SYSTEM ---
export function getXP() {
return parseInt(localStorage.getItem(KEY_XP)) || 0;
}
export function saveXP(xp) {
localStorage.setItem(KEY_XP, xp);
}
export function getLevel(xp) {
// Level = Math.floor(Math.sqrt(xp / 100)) + 1
return Math.floor(Math.sqrt(xp / 100)) + 1;
}
export function getXPForLevel(level) {
if (level <= 1) return 0;
return Math.pow(level - 1, 2) * 100;
}
export function getLevelProgress(xp) {
const currentLevel = getLevel(xp);
const xpCurrentLevelStart = getXPForLevel(currentLevel);
const xpNextLevelStart = getXPForLevel(currentLevel + 1);
const needed = xpNextLevelStart - xpCurrentLevelStart;
const earned = xp - xpCurrentLevelStart;
const percentage = Math.min(100, Math.max(0, (earned / needed) * 100));
return {
currentLevel,
nextLevel: currentLevel + 1,
xpCurrentLevelStart,
xpNextLevelStart,
needed,
earned,
percentage: Math.round(percentage),
};
}
export function addXP(amount) {
const currentXP = getXP();
const oldLevel = getLevel(currentXP);
const newXP = currentXP + amount;
saveXP(newXP);
const newLevel = getLevel(newXP);
return {
newXP,
oldLevel,
newLevel,
leveledUp: newLevel > oldLevel,
};
}
// --- DAILY STREAK ---
export function getStreak() {
const streak = parseInt(localStorage.getItem(KEY_STREAK)) || 0;
const lastDateStr = localStorage.getItem(KEY_STREAK_LAST_DATE);
if (!lastDateStr) return 0;
const today = getTodayString();
const yesterday = getYesterdayString();
// Wenn der letzte Eintrag heute oder gestern war, ist der Streak noch gültig.
// Sonst ist er abgelaufen (0).
if (lastDateStr === today || lastDateStr === yesterday) {
return streak;
}
return 0;
}
export function updateStreak() {
const today = getTodayString();
const yesterday = getYesterdayString();
const lastDateStr = localStorage.getItem(KEY_STREAK_LAST_DATE);
let streak = parseInt(localStorage.getItem(KEY_STREAK)) || 0;
if (lastDateStr === today) {
// Heute bereits gelernt, Streak bleibt gleich
return streak;
} else if (lastDateStr === yesterday) {
// Gestern gelernt -> Streak erhöhen!
streak += 1;
} else {
// Letztes Mal war länger her (oder erster Eintrag) -> Auf 1 setzen
streak = 1;
}
localStorage.setItem(KEY_STREAK, streak);
localStorage.setItem(KEY_STREAK_LAST_DATE, today);
return streak;
}
// --- ACHIEVEMENTS ---
export function getUnlockedAchievements() {
try {
return JSON.parse(localStorage.getItem(KEY_ACHIEVEMENTS)) || [];
} catch (e) {
return [];
}
}
export function unlockAchievement(id) {
const unlocked = getUnlockedAchievements();
if (unlocked.includes(id)) return null; // bereits freigeschaltet
const ach = ACHIEVEMENTS_LIST.find((a) => a.id === id);
if (!ach) return null;
unlocked.push(id);
localStorage.setItem(KEY_ACHIEVEMENTS, JSON.stringify(unlocked));
// XP Bonus gutschreiben
const levelUpResult = addXP(ach.xpBonus);
return {
achievement: ach,
...levelUpResult,
};
}
// Prüft und schaltet Achievements frei basierend auf den aktuellen Quiz-Daten
export function checkAchievements(context) {
const { score, total, timeInSeconds, mode, isRepeatRun } = context;
const newlyUnlocked = [];
// 1. Erster Pfiff (Erstes beendetes Quiz)
const unlocked = getUnlockedAchievements();
if (!unlocked.includes("first_quiz") && !isRepeatRun) {
const res = unlockAchievement("first_quiz");
if (res) newlyUnlocked.push(res);
}
// 2. Fehlerfrei (30/30)
if (score === 30 && total === 30) {
const res = unlockAchievement("perfect_score");
if (res) newlyUnlocked.push(res);
}
// 3. Turboschiri (Beendet in unter 120s und vollzählig 30 Fragen)
if (timeInSeconds < 120 && total === 30 && !isRepeatRun) {
const res = unlockAchievement("turboschiri");
if (res) newlyUnlocked.push(res);
}
// 4. D-Lizenz Meister
if (mode === "d" && score >= 24 && total === 30 && !isRepeatRun) {
const res = unlockAchievement("d_master");
if (res) newlyUnlocked.push(res);
}
// 5. C-Lizenz Meister
if (mode === "c" && score >= 24 && total === 30 && !isRepeatRun) {
const res = unlockAchievement("c_master");
if (res) newlyUnlocked.push(res);
}
// 6. Wiederholungstäter (Falsche Fragen wiederholt und alle gelöst)
if (isRepeatRun && score === total && total > 0) {
const res = unlockAchievement("repeat_master");
if (res) newlyUnlocked.push(res);
}
return newlyUnlocked;
}
// --- HIGHSCORES ---
export function getHighscores() {
try {
return JSON.parse(localStorage.getItem(KEY_HIGHSCORES)) || [];
} catch (e) {
return [];
}
}
export function saveHighscore(score, total, timeInSeconds, mode) {
const scores = getHighscores();
const percentage = (score / total) * 100;
const newEntry = {
score,
total,
percentage: Math.round(percentage * 10) / 10,
time: timeInSeconds,
mode: mode.toUpperCase(),
date: new Date().toLocaleDateString("de-DE"),
};
scores.push(newEntry);
// Sortiere nach Prozent absteigend, bei Gleichstand nach Zeit aufsteigend
scores.sort((a, b) => {
if (b.percentage !== a.percentage) {
return b.percentage - a.percentage;
}
return a.time - b.time;
});
// Nur die Top 5 behalten
const topScores = scores.slice(0, 5);
localStorage.setItem(KEY_HIGHSCORES, JSON.stringify(topScores));
// Prüfen, ob der neue Eintrag in den Top 5 gelandet ist
const isTopScore = topScores.some(
(s) => s.date === newEntry.date && s.score === newEntry.score && s.time === newEntry.time
);
return {
highscores: topScores,
isNewPersonalBest: isTopScore,
};
}
// --- HELPER ---
function getTodayString() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function getYesterdayString() {
const d = new Date();
d.setDate(d.getDate() - 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
+8
View File
@@ -5,4 +5,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})