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 (
);
}
// ———- 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 (