/* 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 ( ); })}
{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 ( ); })}
); } 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 ( ); })}
{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 && ( )}
); } 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();