import React, { useEffect, useMemo, useRef, useState } from “react”; // ===================================== // Lingua app — single-file React demo (v3) // What changed from v2 → v3 (based on your feedback): // – TTS: picks better Spanish voices + optional voice picker in Settings // – Pronunciation: “Slow” speak option + adjustable speech rate // – Study: answers NEVER show until you attempt; wrong shows red banner + correct answer; optional one-card Peek; no more auto-reveals // – Conversations: level‑adaptive prompts (start simple, ramp with XP/level) // – Chat: auto‑scrolls to the newest message // – Mic: quick “Mic Test” button & status right in Conversations // ===================================== // ———- Utilities ———- const ls = { get(key, fallback) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} } }; const removeDiacritics = (s = “”) => { const n = s.normalize(“NFD”); let out = “”; for (const ch of n) { const code = ch.charCodeAt(0); if (code < 0x0300 || code > 0x036f) out += ch; } return out.toLowerCase().trim(); }; function editDistance(a, b) { const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0)); for (let i = 0; i <= a.length; i++) dp[i][0] = i; for (let j = 0; j <= b.length; j++) dp[0][j] = j; for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost); } } return dp[a.length][b.length]; } const isNearMatch = (a, b) => { const an = removeDiacritics(a || “”); const bn = removeDiacritics(b || “”); if (!bn) return false; if (an === bn) return true; const d = editDistance(an, bn); const tol = bn.length <= 5 ? 1 : bn.length <= 10 ? 2 : 3; return d <= tol; }; // ---------- Data ---------- const LEVELS = ["A1", "A2", "B1", "B2", "C1"]; const TOPICS = ["open", "food", "business", "travel", "home", "family", "health", "shopping", "directions", "small talk", "abstract"]; // Minimal demo bank (extend later) const WORD_BANK = [ // A1 { id: "hola", en: "hello", es: "hola", topic: "small talk", level: "A1" }, { id: "adios", en: "goodbye", es: "adiós", topic: "small talk", level: "A1" }, { id: "gracias", en: "thank you", es: "gracias", topic: "small talk", level: "A1" }, { id: "por-favor", en: "please", es: "por favor", topic: "small talk", level: "A1" }, { id: "si", en: "yes", es: "sí", topic: "small talk", level: "A1" }, { id: "no", en: "no", es: "no", topic: "small talk", level: "A1" }, { id: "perdon", en: "sorry", es: "perdón", topic: "small talk", level: "A1" }, { id: "bano", en: "bathroom", es: "baño", topic: "travel", level: "A1" }, { id: "agua", en: "water", es: "agua", topic: "food", level: "A1" }, { id: "comida", en: "food", es: "comida", topic: "food", level: "A1" }, { id: "cuanto-cuesta", en: "how much is it?", es: "¿cuánto cuesta?", topic: "shopping", level: "A1" }, { id: "donde-esta", en: "where is..?", es: "¿dónde está..?", topic: "directions", level: "A1" }, // A2 { id: "me-llamo", en: "my name is", es: "me llamo", topic: "small talk", level: "A2" }, { id: "soy-de", en: "I'm from", es: "soy de", topic: "small talk", level: "A2" }, { id: "trabajo", en: "work/job", es: "trabajo", topic: "business", level: "A2" }, { id: "cuenta", en: "bill/check", es: "cuenta", topic: "food", level: "A2" }, { id: "reservacion", en: "reservation", es: "reservación", topic: "travel", level: "A2" }, { id: "izquierda", en: "left", es: "izquierda", topic: "directions", level: "A2" }, { id: "derecha", en: "right", es: "derecha", topic: "directions", level: "A2" }, // B1 { id: "alergico", en: "allergic", es: "alérgico", topic: "health", level: "B1" }, { id: "factura", en: "invoice", es: "factura", topic: "business", level: "B1" }, { id: "llegar", en: "to arrive", es: "llegar", topic: "travel", level: "B1" }, { id: "salir", en: "to leave", es: "salir", topic: "travel", level: "B1" }, { id: "propina", en: "tip", es: "propina", topic: "food", level: "B1" }, { id: "hogar", en: "home", es: "hogar", topic: "home", level: "B1" }, // B2 { id: "reunion", en: "meeting", es: "reunión", topic: "business", level: "B2" }, { id: "contrato", en: "contract", es: "contrato", topic: "business", level: "B2" }, { id: "estrategia", en: "strategy", es: "estrategia", topic: "business", level: "B2" }, // C1 { id: "matiz", en: "nuance", es: "matiz", topic: "abstract", level: "C1" }, { id: "sintesis", en: "synthesis", es: "síntesis", topic: "abstract", level: "C1" }, ]; // ---------- Speech (TTS + STT) ---------- function useSpeech() { const [supported, setSupported] = useState({ tts: false, stt: false }); const [voices, setVoices] = useState([]); const recRef = useRef(null); const [listening, setListening] = useState(false); const [lastTranscript, setLastTranscript] = useState(""); const voicesReadyRef = useRef(false); useEffect(() => { const tts = typeof window !== “undefined” && “speechSynthesis” in window; const STT = typeof window !== “undefined” && (“webkitSpeechRecognition” in window || “SpeechRecognition” in window); setSupported({ tts, stt: !!STT }); if (tts) { const loadVoices = () => { const v = window.speechSynthesis.getVoices?.() || []; if (v.length > 0) { voicesReadyRef.current = true; setVoices(v); } }; loadVoices(); window.speechSynthesis.onvoiceschanged = loadVoices; } if (STT) { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const rec = new SR(); rec.lang = “es-ES”; rec.interimResults = false; rec.maxAlternatives = 1; rec.onresult = (e) => { const text = e.results[0][0].transcript; setLastTranscript(text); setListening(false); }; rec.onerror = () => setListening(false); rec.onend = () => setListening(false); recRef.current = rec; } }, []); function pickVoice({ list = voices, lang = “es-ES”, preferredName }) { if (!list || list.length === 0) return null; if (preferredName) { const exact = list.find((v) => v.name === preferredName); if (exact) return exact; } const byLang = list.filter((v) => (v.lang || “”).toLowerCase().startsWith(lang.toLowerCase())); if (byLang.length) { // Sort for nicer voices: Natural/Online > Microsoft > Google > Apple > others const score = (v) => { const n = (v.name || “”).toLowerCase(); let s = 0; if (n.includes(“natural”) || n.includes(“online”)) s += 3; if (n.includes(“microsoft”)) s += 2; if (n.includes(“google”)) s += 1.5; if (n.includes(“apple”)) s += 1; return s; }; return byLang.sort((a, b) => score(b) – score(a))[0]; } // Fallback: any Spanish const anyEs = list.filter((v) => (v.lang || “”).toLowerCase().startsWith(“es”)); return anyEs[0] || list[0]; } const speak = (text, { lang = “es-ES”, voiceName, rate = 1.0, pitch = 1.0 } = {}) => { if (!supported.tts || !text) return; const say = () => { const u = new SpeechSynthesisUtterance(text); u.lang = lang; u.rate = rate; u.pitch = pitch; try { const v = pickVoice({ list: voices, lang, preferredName: voiceName }); if (v) u.voice = v; } catch {} try { window.speechSynthesis.cancel(); } catch {} try { window.speechSynthesis.resume?.(); } catch {} window.speechSynthesis.speak(u); }; if (!voicesReadyRef.current) setTimeout(say, 60); else say(); }; const startListening = (lang = “es-ES”) => { if (!supported.stt || !recRef.current) return; recRef.current.lang = lang; setLastTranscript(“”); setListening(true); try { recRef.current.start(); } catch {} }; return { supported, voices, speak, startListening, listening, lastTranscript, setLastTranscript }; } // ———- Profiles & persistence ———- function loadProfiles() { return ls.get(“lingua_profiles”, {}); } function saveProfiles(p) { ls.set(“lingua_profiles”, p); } function getEmptyProfile(name) { return { name, level: null, xp: 0, knownIds: [], settings: { dialect: “es-ES”, // or es-MX voiceName: null, // pick best automatically if null ttsRate: 1.0, autoSpeak: true, acceptVoiceInput: true, showTranslations: false, }, }; } // ———- Placement ———- const PLACEMENT_QUESTIONS = (() => { const picks = []; const perLevel = 2; for (const L of LEVELS) { const words = WORD_BANK.filter((w) => w.level === L).slice(0, 4); for (let i = 0; i < Math.min(perLevel, words.length); i++) { const word = words[i]; picks.push({ id: `pl-${L}-${i}`, promptLang: Math.random() < 0.5 ? "en" : "es", word }); } } return picks; })(); function levelFromScore(score, max) { const pct = score / max; if (pct >= 0.85) return “C1”; if (pct >= 0.7) return “B2”; if (pct >= 0.55) return “B1”; if (pct >= 0.4) return “A2”; return “A1”; } // ———- UI atoms ———- const Button = ({ children, onClick, disabled, variant = “primary”, title }) => { const base = “px-3 py-2 rounded-xl text-sm font-medium shadow-sm transition active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed”; const styles = variant === “primary” ? “bg-violet-600 hover:bg-violet-700 text-white” : variant === “ghost” ? “bg-transparent hover:bg-slate-100 border border-slate-200” : variant === “danger” ? “bg-rose-600 hover:bg-rose-700 text-white” : “bg-slate-700 hover:bg-slate-800 text-white”; return ; }; const Card = ({ title, subtitle, right, children }) => (

{title}

{subtitle &&

{subtitle}

}
{right}
{children}
); const Badge = ({ children }) => {children}; const Toggle = ({ label, checked, onChange }) => ( ); // ———- Screens ———- function LoginScreen({ onLogin }) { const [name, setName] = useState(“”); const profiles = loadProfiles(); const names = Object.keys(profiles); return (
{names.length > 0 && (
{names.map((n) => ())}
)}
setName(e.target.value)} />
); } function PlacementTest({ onFinish }) { const [answers, setAnswers] = useState({}); const max = PLACEMENT_QUESTIONS.length; const score = useMemo(() => PLACEMENT_QUESTIONS.reduce((s, q) => { const val = answers[q.id]; if (!val) return s; const expected = q.promptLang === “en” ? q.word.es : q.word.en; return s + (isNearMatch(val, expected) ? 1 : 0); }, 0), [answers]); const levelGuess = levelFromScore(score, max); return (
{score}/{max} → level {levelGuess}}>
    {PLACEMENT_QUESTIONS.map((q) => (
  1. {q.word.level}{q.promptLang === “en” ? “EN→ES” : “ES→EN”} {q.promptLang === “en” ? <>Translate to Spanish: {q.word.en} : <>Translate to English: {q.word.es}}
    setAnswers((a) => ({ …a, [q.id]: e.target.value }))} />
  2. ))}
You can edit answers before submitting.
); } // ———- Study ———- function nextLesson(profile) { const curLevel = profile.level || “A1”; const levelIdx = LEVELS.indexOf(curLevel); const pool = WORD_BANK.filter((w) => LEVELS.indexOf(w.level) <= levelIdx + 1); const unknown = pool.filter((w) => !profile.knownIds.includes(w.id)); const take = (arr, n) => arr.sort(() => 0.5 – Math.random()).slice(0, n); const base = (unknown.length ? take(unknown, 8) : take(pool, 8)); return base.map((w) => ({ word: w, correct: false, attempted: false, dir: Math.random() < 0.5 ? "EN→ES" : "ES→EN" })); } function StudySection({ profile, onProfileChange, speech }) { const [lesson, setLesson] = useState(() => nextLesson(profile)); const [idx, setIdx] = useState(0); const [answer, setAnswer] = useState(“”); const [peek, setPeek] = useState(false); // only for CURRENT card const [feedback, setFeedback] = useState(null); const [lessonDone, setLessonDone] = useState(false); useEffect(() => { setPeek(false); // reset per card setFeedback(null); if (profile.settings.autoSpeak) { const q = currentPrompt(); speech.speak(q.text, { lang: dirIsENtoES() ? profile.settings.dialect : “en-US”, voiceName: profile.settings.voiceName, rate: profile.settings.ttsRate }); } // eslint-disable-next-line }, [idx]); useEffect(() => { if (speech.lastTranscript && !speech.listening) setAnswer(speech.lastTranscript); }, [speech.lastTranscript, speech.listening]); function dirIsENtoES(i = idx) { return (lesson[i].dir || “EN→ES”) === “EN→ES”; } function currentItem() { return lesson[idx]; } function currentPrompt() { const item = currentItem(); return dirIsENtoES() ? { lang: “en”, text: item.word.en } : { lang: “es”, text: item.word.es }; } function expectedAnswer() { const item = currentItem(); return dirIsENtoES() ? item.word.es : item.word.en; } function commit(correct) { const item = currentItem(); const expTxt = expectedAnswer(); const updatedLesson = lesson.map((it, i) => (i === idx ? { …it, correct, attempted: true } : it)); if (correct && !profile.knownIds.includes(item.word.id)) { onProfileChange({ …profile, knownIds: […profile.knownIds, item.word.id], xp: profile.xp + 5 }); } else if (correct) { onProfileChange({ …profile, xp: profile.xp + 2 }); } setLesson(updatedLesson); setFeedback({ correct, msg: correct ? “✅ Correct!” : `❌ Not quite. Correct answer: ${expTxt}` }); if (!correct) { // speak the correct answer in the target language speech.speak(expTxt, { lang: dirIsENtoES() ? profile.settings.dialect : “en-US”, voiceName: profile.settings.voiceName, rate: profile.settings.ttsRate }); } } function onSubmit() { const ok = isNearMatch(answer, expectedAnswer()); commit(ok); setAnswer(“”); if (idx < lesson.length - 1) { setTimeout(() => setIdx(idx + 1), 200); // tiny delay so feedback is visible } else { setLessonDone(true); } } function restart() { setLesson(nextLesson(profile)); setIdx(0); setAnswer(“”); setPeek(false); setFeedback(null); setLessonDone(false); } const item = currentItem(); const prompt = currentPrompt(); const exp = expectedAnswer(); return (
}>
Card {idx + 1} / {lesson.length} · Direction {item.dir}
{prompt.text}
{(peek) && (
Answer: {exp}
)} {feedback && (
{feedback.msg}
)}
setAnswer(e.target.value)} onKeyDown={(e) => e.key === “Enter” && onSubmit()} /> {profile.settings.acceptVoiceInput && speech.supported.stt && ( )}
{speech.lastTranscript && (
Heard: “{speech.lastTranscript}”
)}
{lesson.map((it, i) => { const showAns = it.attempted; // only after attempt const promptSide = (it.dir || “EN→ES”) === “EN→ES” ? it.word.en : it.word.es; const answerSide = (it.dir || “EN→ES”) === “EN→ES” ? it.word.es : it.word.en; return (
{it.dir} {promptSide} / {showAns ? answerSide : “••••”} {it.correct ? “✅” : it.attempted ? “❌” : i === idx ? “➡️” : “”}
); })}
{profile.knownIds.length === 0 ? ( None yet — let’s learn. ) : ( profile.knownIds.map((id) => { const w = WORD_BANK.find((x) => x.id === id); if (!w) return null; return ( {w.en} · {w.es} ); }) )}
{lessonDone && (

Lesson complete

You finished this set. Continue with a new lesson?

)}
); } // ———- Conversations ———- function translateToEn(text) { const dict = { bienvenido: “welcome”, bienvenidos: “welcome”, modo: “mode”, conversacion: “conversation”, “conversacion.”: “conversation.”, empecemos: “let’s begin”, hablemos: “let’s talk”, de: “about”, algo: “something”, que: “what”, opinas: “do you think”, puedes: “can you”, usar: “use”, la: “the”, palabra: “word”, en: “in”, una: “a”, frase: “sentence”, pregunta: “question”, rapida: “quick”, como: “how”, dices: “do you say”, cuentame: “tell me”, sobre: “about”, tu: “your”, experiencia: “experience”, practiquemos: “let’s practice”, completa: “complete”, pista: “hint”, me: “me”, gusta: “like”, }; const normalize = (s) => removeDiacritics(s || “”); const tokens = (normalize(text)).split(/([\s,!.?¿¡]+)/); const wordsMap = Object.fromEntries(WORD_BANK.map((w) => [normalize(w.es), w.en])); const out = tokens.map((t) => { const n = normalize(t); if (/^[\s,!.?¿¡]+$/.test(t)) return t; return dict[n] || wordsMap[n] || t; }).join(“”); return out.replace(/(^\s*[a-z])/, (m) => m.toUpperCase()); } function translateToEs(text) { const map = Object.fromEntries(WORD_BANK.map((w) => [removeDiacritics(w.en), w.es])); return text.split(/([\s,!.?¿¡]+)/).map((t) => map[removeDiacritics(t)] || t).join(“”); } function complexityByLevel(level, xp) { // Returns a numeric step 1..5 roughly mapping to A1..C1 + XP const base = Math.max(1, LEVELS.indexOf(level || “A1”) + 1); const bonus = xp > 150 ? 2 : xp > 60 ? 1 : 0; return Math.min(5, base + bonus); } function synthesizeTurn({ topic, profile }) { const lvl = complexityByLevel(profile.level, profile.xp); const known = profile.knownIds.map((id) => WORD_BANK.find((w) => w.id === id)).filter(Boolean); const pool = WORD_BANK.filter((w) => (topic === “open” ? true : w.topic === topic)); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; const kw = (known.length ? pick(known) : pick(pool)) || pick(WORD_BANK); const T1 = [ `Hablemos de ${topic === “open” ? “algo” : topic}.`, `¿Cómo dices “${kw?.en}” en español?`, `Di una frase con “${kw?.es}”.`, ]; const T2 = [ `¿Qué te gusta de ${topic === “open” ? “tu día” : topic}?`, `Completa: “Me gusta la ___” (pista: ${topic}).`, `Usa “${kw?.es}” en una oración corta.`, ]; const T3 = [ `Describe ${topic === “open” ? “tu rutina” : `algo de ${topic}` } en dos frases.`, `Haz una pregunta con “${kw?.es}”.`, ]; const T4 = [ `Da tu opinión sobre ${topic}. Usa conectores como “porque” o “pero”.`, `Cuenta una experiencia con ${topic} usando pasado.`, ]; const T5 = [ `Argumenta a favor o en contra de algo sobre ${topic} en 3–4 frases.`, `Compara dos opciones relacionadas con ${topic} usando comparativos.`, ]; const bank = [T1, T2, T3, T4, T5][Math.max(0, Math.min(4, lvl – 1))]; return pick(bank); } function ConversationSection({ profile, onProfileChange, speech }) { const [topic, setTopic] = useState(“open”); const [messages, setMessages] = useState([{ role: “system”, text: “Bienvenido al modo conversación. ¡Empecemos!”, lang: “es” }]); const [input, setInput] = useState(“”); const [showTranslations, setShowTranslations] = useState(profile.settings.showTranslations); const listRef = useRef(null); useEffect(() => { if (profile.settings.autoSpeak) { const last = messages[messages.length – 1]; if (last && last.role !== “user”) speech.speak(last.text, { lang: profile.settings.dialect, voiceName: profile.settings.voiceName, rate: profile.settings.ttsRate }); } }, [messages]); useEffect(() => { // Auto-scroll to bottom on new messages const el = listRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages]); function sendUserMessage(text) { if (!text.trim()) return; const userMsg = { role: “user”, text: text.trim(), lang: “es” }; setMessages((m) => […m, userMsg]); const reply = synthesizeTurn({ topic, profile }); setTimeout(() => setMessages((m) => […m, { role: “assistant”, text: reply, lang: “es” }]), 150); onProfileChange({ …profile, xp: profile.xp + 1 }); } const micActive = speech.listening; return (
}>
{messages.map((m, i) => (
{m.text}
{showTranslations && (
{m.lang === “es” ? `EN: ${translateToEn(m.text)}` : `ES: ${translateToEs(m.text)}`}
)}
))}
setInput(e.target.value)} onKeyDown={(e) => e.key === “Enter” && (sendUserMessage(input), setInput(“”))} />
{speech.lastTranscript && (
Mic heard: “{speech.lastTranscript}” →
)}
  • Prompts start simple for A1/A2, grow with your XP.
  • Mic Test fills the input; press Send to submit.
  • Use “Show translation” to peek at an English gloss.
); } // ———- Settings & Dashboard ———- function SettingsModal({ open, onClose, profile, onSave, speech }) { const [dialect, setDialect] = useState(profile.settings.dialect || “es-ES”); const [voiceName, setVoiceName] = useState(profile.settings.voiceName || “”); const [ttsRate, setTtsRate] = useState(profile.settings.ttsRate ?? 1.0); const [autoSpeak, setAutoSpeak] = useState(!!profile.settings.autoSpeak); const [acceptVoiceInput, setAcceptVoiceInput] = useState(!!profile.settings.acceptVoiceInput); const [showTranslations, setShowTranslations] = useState(!!profile.settings.showTranslations); if (!open) return null; const spanishVoices = (speech.voices || []).filter((v) => (v.lang || “”).toLowerCase().startsWith(“es”)); return (

Settings

For natural voices, try Edge/Chrome on desktop. We can hook cloud TTS later (e.g., Azure/Google/Polly) for studio-quality speech.

setTtsRate(parseFloat(e.target.value))} className=”w-full” />
{ttsRate.toFixed(2)}×
); } function DashboardHeader({ profile, onSignOut, onUpdateSettings }) { return (

Hola, {profile.name} 👋

Level: {profile.level || “Unplaced”} · XP: {profile.xp} · Known words: {profile.knownIds.length}

); } // ———- App ———- export default function App() { const [profiles, setProfiles] = useState(loadProfiles()); const [currentName, setCurrentName] = useState(ls.get(“lingua_current”, null)); const [showSettings, setShowSettings] = useState(false); const speech = useSpeech(); const profile = currentName ? profiles[currentName] : null; useEffect(() => saveProfiles(profiles), [profiles]); useEffect(() => ls.set(“lingua_current”, currentName), [currentName]); function ensureProfile(name) { setProfiles((p) => (p[name] ? p : { …p, [name]: getEmptyProfile(name) })); setCurrentName(name); } function updateProfile(next) { setProfiles((p) => ({ …p, [next.name]: next })); } function signOut() { setCurrentName(null); } const bgClass = “from-sky-50 via-white to-fuchsia-50”; return (

Lingua app — Spanish

Placement → Study → Converse. Type or speak. Learn fast.

{!profile ? ( ) : !profile.level ? ( updateProfile({ …profile, level })} /> ) : ( <> setShowSettings(true)} />
)}
{profile && ( setShowSettings(false)} profile={profile} onSave={(s) => updateProfile({ …profile, settings: { …profile.settings, …s } })} speech={speech} />)}
); }