/* global React, ReactDOM, QUIZ_DATA */
const { useState, useEffect, useRef, useMemo } = React;
/* =========================================================================
STORAGE
========================================================================= */
const STORAGE_KEY = "smaglo_quiz_v1";
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { history: [], unlocked: {} };
const parsed = JSON.parse(raw);
return {
history: parsed.history || [],
unlocked: parsed.unlocked || {},
};
} catch (e) {
return { history: [], unlocked: {} };
}
}
function saveState(s) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
}
const PASS_THRESHOLD = 0.7; // 70% to pass a level
function isUnlocked(state, lang, level) {
const langData = QUIZ_DATA[lang];
const idx = langData.levels.indexOf(level);
if (idx === 0) return true;
const prev = langData.levels[idx - 1];
const key = `${lang}:${prev}`;
return !!(state.unlocked[key] && state.unlocked[key].passed);
}
function bestScore(state, lang, level) {
const matches = state.history.filter(h => h.lang === lang && h.level === level);
if (matches.length === 0) return null;
return Math.max(...matches.map(h => h.pct));
}
function levelCongrats(level) {
const map = {
A1: ["A1 пройден 🎉", "База уверенно в кармане. Ты уже можешь представиться, заказать кофе и спросить дорогу. Пора к A2."],
A2: ["A2 пройден 🎉", "Можешь поддержать бытовой разговор и рассказать о себе. Время переходить к B1 — здесь начинается настоящий язык."],
B1: ["B1 пройден 🎉", "Ты свободнее, чем думаешь. Понимаешь фильмы, ведёшь переписку, путешествуешь один. Впереди B2 — академический уровень."],
B2: ["B2 пройден 🎉", "Это уровень, с которого говорят «я знаю язык». Доступны экзамены FCE/DELF и серьёзная работа на иностранном."],
C1: ["C1 пройден 🎉", "Advanced. Ты уверенно работаешь, учишься и ведёшь переговоры. Остаётся C2 — noble level."],
C2: ["C2 пройден 🎉", "Mastery. Тебя не отличить от носителя. Дальше — только шлифовка и удовольствие от языка."],
HSK1: ["HSK 1 пройден 🎉", "150 базовых слов и простые фразы. Ты сделал первый шаг — и это сложнее всего. Вперёд к HSK 2."],
HSK2: ["HSK 2 пройден 🎉", "300 слов, можешь вести простые разговоры. Китайский перестал быть «непонятными иероглифами»."],
HSK3: ["HSK 3 пройден 🎉", "600 слов. Ты уверенно читаешь, пишешь и понимаешь на слух базовые темы. Middle level unlocked."],
HSK4: ["HSK 4 пройден 🎉", "1200 слов. Можешь работать и учиться на китайском. Следующий уровень — уже серьёзная академическая планка."],
HSK5: ["HSK 5 пройден 🎉", "2500 слов. Свободно читаешь газеты, смотришь фильмы, ведёшь переговоры. Впереди — HSK 6, вершина."],
HSK6: ["HSK 6 пройден 🎉", "5000+ слов. Носитель тебе не ровня, а друг. Mastery level."],
};
return map[level] || [`${level} пройден 🎉`, "Отличная работа. Ты продвигаешься."];
}
/* =========================================================================
UTILS
========================================================================= */
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pickQuestions(lang, level, n = 10) {
const pool = QUIZ_DATA[lang].questions[level] || [];
return shuffle(pool).slice(0, Math.min(n, pool.length)).map((q, i) => ({ ...q, _i: i }));
}
function formatDate(ts) {
const d = new Date(ts);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
return `${dd}.${mm} · ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/* =========================================================================
CONFETTI
========================================================================= */
function fireConfetti() {
const layer = document.getElementById("confettiLayer");
if (!layer) return;
const colors = ["#FF2E88", "#00E5FF", "#B794FF", "#FFE66B"];
const count = 80;
for (let i = 0; i < count; i++) {
const p = document.createElement("span");
p.className = "confetti-piece";
const x = 50 + (Math.random() - 0.5) * 30; // near center horizontally
const dx = (Math.random() - 0.5) * 800;
const r = 360 + Math.random() * 720;
p.style.left = x + "vw";
p.style.top = "40vh";
p.style.background = colors[i % colors.length];
p.style.borderRadius = Math.random() > 0.5 ? "50%" : "2px";
p.style.setProperty("--dx", dx + "px");
p.style.setProperty("--r", r + "deg");
p.style.animationDelay = (Math.random() * 0.15) + "s";
layer.appendChild(p);
setTimeout(() => p.remove(), 2000);
}
}
/* =========================================================================
SUBTLE CURSOR TRAIL (copied from main site)
========================================================================= */
function useCursorTrail() {
useEffect(() => {
const layer = document.getElementById("cursorTrail");
let lastX = 0, lastY = 0, lastT = 0;
const onMove = (e) => {
const now = performance.now();
const dx = e.clientX - lastX, dy = e.clientY - lastY;
const dist = Math.hypot(dx, dy);
if (now - lastT < 28 || dist < 14) return;
lastX = e.clientX; lastY = e.clientY; lastT = now;
if (!layer) return;
const p = document.createElement("span");
p.className = "trail-dot";
p.style.left = e.clientX + "px";
p.style.top = e.clientY + "px";
layer.appendChild(p);
setTimeout(() => p.remove(), 900);
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
}
/* =========================================================================
SCREENS
========================================================================= */
const LANG_GREETINGS = {
en: { word: "Hello", label: "English", meta: "CEFR · A1 → C2" },
fr: { word: "Bonjour", label: "Français", meta: "CEFR · A1 → C2" },
zh: { word: "你好", label: "中文", meta: "HSK 1 → HSK 6" },
mix: { word: "Hello · Bonjour · 你好", label: "Все языки", meta: "Микс · A1 → C2" },
};
function Home({ state, onPick }) {
const totalAttempts = state.history.length;
const langs = ["en", "fr", "zh", "mix"];
return (
<>
Q · 01
quiz.system / v1.0
SMAGLO · 2026
Проверь свой уровень
за 10 вопросов.
Выбери язык — и начни с самого простого. Прошёл уровень на 70%+ — откроется следующий. Прогресс сохраняется локально в браузере.
{langs.map((lang, i) => {
const g = LANG_GREETINGS[lang];
return (
onPick(lang)}
>
{String(i+1).padStart(2,"0")} / {String(langs.length).padStart(2,"0")}
{lang.toUpperCase()}
{g.word}
{g.label}
{g.meta}
);
})}
{totalAttempts > 0 && {
if (confirm("Удалить всю историю попыток?")) {
saveState({ history: [], unlocked: {} });
window.location.reload();
}
}} />}
>
);
}
function LevelPicker({ lang, state, onBack, onStart }) {
const data = QUIZ_DATA[lang];
const g = LANG_GREETINGS[lang];
return (
Q · 02
select.level / {data.system}
← назад
{g.word === "你好" ? 你好 : g.word === "Bonjour" ? Bonjour : g.word === "Hello" ? Hello : Mix }
Выбери уровень. Начни с самого простого — следующие откроются, когда ты пройдёшь предыдущий на 70%+.
{data.levels.map((level, idx) => {
const unlocked = isUnlocked(state, lang, level);
const key = `${lang}:${level}`;
const passed = state.unlocked[key] && state.unlocked[key].passed;
const best = bestScore(state, lang, level);
return (
unlocked && onStart(lang, level)}
disabled={!unlocked}
>
{String(idx+1).padStart(2,"0")} / {String(data.levels.length).padStart(2,"0")}
{passed ? (
пройден
) : unlocked ? (
доступен
) : (
🔒 заблокирован
)}
{level}
{levelName(level)}
{best !== null && (
best
{Math.round(best * 100)}%
)}
);
})}
);
}
function levelName(level) {
const map = {
A1: "Начальный",
A2: "Элементарный",
B1: "Средний",
B2: "Средне-продвинутый",
C1: "Продвинутый",
C2: "Мастер",
HSK1: "150 слов, база",
HSK2: "300 слов, быт",
HSK3: "600 слов, разговор",
HSK4: "1200 слов, работа",
HSK5: "2500 слов, свобода",
HSK6: "5000+ слов, мастерство",
};
return map[level] || "";
}
function Game({ lang, level, state, onBack, onFinish }) {
const [questions] = useState(() => pickQuestions(lang, level, 10));
const [idx, setIdx] = useState(0);
const [selected, setSelected] = useState(null);
const [answers, setAnswers] = useState([]); // { correct: bool, type }
const [streak, setStreak] = useState(0);
const q = questions[idx];
const total = questions.length;
const progress = (idx + (selected !== null ? 1 : 0)) / total;
if (!q) return null;
const pick = (optIdx) => {
if (selected !== null) return;
setSelected(optIdx);
const isRight = optIdx === q.correct;
setAnswers(a => [...a, { correct: isRight, type: q.type }]);
if (isRight) {
setStreak(s => s + 1);
} else {
setStreak(0);
}
// auto-advance
const delay = isRight ? 900 : 1800;
setTimeout(() => {
setAnswers(curr => {
// use latest answers at fire time
if (idx + 1 >= total) {
const correct = curr.filter(a => a.correct).length;
const pct = correct / total;
onFinish({
lang, level, pct,
correct, total,
answers: [...curr],
ts: Date.now(),
});
} else {
setIdx(i => i + 1);
setSelected(null);
}
return curr;
});
}, delay);
};
const typeLabel = {
translate: "перевод",
image: "картинка",
fill: "пропуск",
idiom: "идиома",
grammar: "грамматика",
}[q.type] || q.type;
const isImageOptions = q.type === "image";
return (
← выйти
{QUIZ_DATA[lang].label} · {level}
{idx + 1} / {total}
{streak >= 2 && (
streak × {streak}
)}
◆ {typeLabel}
{q._lang && {q._lang} }
{q.type === "translate" ? "переведи" :
q.type === "image" ? "выбери картинку" :
q.type === "fill" ? "заполни пропуск" :
q.type === "idiom" ? "что значит" :
q.type === "grammar" ? "выбери правильную форму" : ""}
{q.type === "fill" || q.type === "grammar"
? ___') }} />
: q.prompt}
{q.options.map((opt, i) => {
const isSelected = selected === i;
const isCorrect = selected !== null && i === q.correct;
const isWrong = isSelected && i !== q.correct;
const dim = selected !== null && !isCorrect && !isWrong;
return (
pick(i)}
>
{"ABCD"[i]}
{opt}
);
})}
{selected !== null && (
{selected === q.correct ? "✓" : "✕"}
{selected === q.correct
? <>Верно. {q.note || ""}>
: <>Правильный ответ: {q.options[q.correct]}{q.note ? ` — ${q.note}` : ""}>
}
)}
);
}
function Result({ run, state, onRetry, onLevels, onHome }) {
const { lang, level, pct, correct, total } = run;
const passed = pct >= PASS_THRESHOLD;
const data = QUIZ_DATA[lang];
const lvlIdx = data.levels.indexOf(level);
const nextLevel = data.levels[lvlIdx + 1];
const [title, text] = levelCongrats(level);
// Breakdown by type
const byType = {};
run.answers.forEach(a => {
if (!byType[a.type]) byType[a.type] = { c: 0, t: 0 };
byType[a.type].t++;
if (a.correct) byType[a.type].c++;
});
useEffect(() => {
if (passed) setTimeout(fireConfetti, 300);
}, [passed]); return (
результат · {data.label} · {level}
{passed ? (
<>{title.split(" ")[0]} {title.split(" ")[1]} {title.split(" ").slice(2).join(" ")}>
) : (
<>Ещё чуть-чуть. >
)}
{passed
? text
: `Ты ответил верно на ${correct} из ${total} (${Math.round(pct*100)}%). Чтобы разблокировать следующий уровень, нужно 70%+. Давай ещё разок.`}
{passed && nextLevel && (
↗
Открыт уровень {nextLevel}. Ты можешь перейти к нему прямо сейчас или потренироваться ещё на текущем.
)}
{Math.round(pct*100)}%
точность
{correct}/{total}
верных ответов
{passed ? "PASS" : "—"}
статус
{Object.keys(byType).length > 1 && (
разбор по типам
{Object.entries(byType).map(([type, v]) => {
const tl = { translate: "перевод", image: "картинки", fill: "пропуски", idiom: "идиомы", grammar: "грамматика" }[type] || type;
return (
{tl}
{v.c}/{v.t}
);
})}
)}
{passed && nextLevel && (
onRetry(lang, nextLevel)}>
уровень {nextLevel} →
)}
onRetry(lang, level)}>
ещё раз
другой уровень
другой язык
);
}
function History({ state, onWipe }) {
const sorted = [...state.history].sort((a,b) => b.ts - a.ts).slice(0, 10);
return (
История попыток
очистить
{sorted.map((h, i) => (
{QUIZ_DATA[h.lang]?.label || h.lang}
{formatDate(h.ts)}
{h.level}
{Math.round(h.pct*100)}%
{h.correct}/{h.total}
= PASS_THRESHOLD ? "pass" : "fail"}`}>
{h.pct >= PASS_THRESHOLD ? "✓ passed" : "× retry"}
))}
);
}
/* =========================================================================
MAIN APP
========================================================================= */
function App() {
useCursorTrail();
const [state, setState] = useState(() => loadState());
const [screen, setScreen] = useState("home"); // home | levels | game | result
const [sel, setSel] = useState({ lang: null, level: null });
const [run, setRun] = useState(null);
useEffect(() => { saveState(state); }, [state]);
const onPickLang = (lang) => {
setSel({ lang, level: null });
setScreen("levels");
};
const onStart = (lang, level) => {
setSel({ lang, level });
setScreen("game");
};
const onFinish = (result) => {
setRun(result);
setState(s => {
const nextHistory = [...s.history, result];
const key = `${result.lang}:${result.level}`;
const passed = result.pct >= PASS_THRESHOLD;
const nextUnlocked = { ...s.unlocked };
if (passed) {
nextUnlocked[key] = { passed: true, ts: result.ts };
} else if (!nextUnlocked[key]) {
nextUnlocked[key] = { passed: false, ts: result.ts };
}
return { history: nextHistory, unlocked: nextUnlocked };
});
setScreen("result");
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<>
{screen === "home" && }
{screen === "levels" && sel.lang && (
setScreen("home")}
onStart={onStart}
/>
)}
{screen === "game" && sel.lang && sel.level && (
setScreen("levels")}
onFinish={onFinish}
/>
)}
{screen === "result" && run && (
{ setSel({ lang, level }); setScreen("game"); }}
onLevels={() => setScreen("levels")}
onHome={() => setScreen("home")}
/>
)}
>
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );