/* Spanish A1 React App (single-file App component) Features included: - Flashcards (Spanish <-> English) - Multiple-choice Practice - Conversation (fill-in-the-blank / translate) with tolerant checking - Progress tracking saved in localStorage (points, level, accuracy, streak) - Simple gamification (points + levels) - Tailwind-ready classes (use with a Vite + Tailwind project) How to run (quick setup): 1) Create a new Vite React app: npm create vite@latest spanish-a1 -- --template react cd spanish-a1 2) Install dependencies and Tailwind (follow Tailwind + Vite guide): npm install npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p // tailwind.config.cjs -> add: // module.exports = { content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], theme: { extend: {} }, plugins: [], } // src/index.css -> replace with Tailwind base imports: // @tailwind base; // @tailwind components; // @tailwind utilities; 3) Replace src/App.jsx contents with the code in this file (export default App at bottom). 4) Start dev server: npm run dev You can also bundle using `npm run build` and serve the dist folder. Note: This file is written as a single React component file to paste into src/App.jsx. It assumes Tailwind is active so classes will style correctly. If you don't want Tailwind, the markup will still function — you'll just need to add styles. */ import React, { useEffect, useMemo, useState } from 'react' // ---------- Small A1 dataset ---------- const VOCAB = [ { es: 'hola', en: 'hello' }, { es: 'adiós', en: 'goodbye' }, { es: 'por favor', en: 'please' }, { es: 'gracias', en: 'thank you' }, { es: 'lo siento', en: 'sorry' }, { es: 'sí', en: 'yes' }, { es: 'no', en: 'no' }, { es: '¿cómo estás?', en: 'how are you?' }, { es: 'bien', en: 'well/fine' }, { es: 'mal', en: 'bad' }, { es: '¿qué tal?', en: "what's up?" }, { es: 'buenos días', en: 'good morning' }, { es: 'buenas noches', en: 'good night' }, { es: '¿cómo te llamas?', en: "what's your name?" }, { es: 'me llamo...', en: 'my name is...' }, ] const PHRASES = [ { es: '¿Dónde está el baño?', en: 'Where is the bathroom?' }, { es: 'Necesito ayuda', en: 'I need help' }, { es: '¿Cuánto cuesta?', en: 'How much does it cost?' }, { es: 'No entiendo', en: "I don't understand" }, { es: '¿Puede repetirlo, por favor?', en: 'Can you repeat that, please?' }, ] const MC_QUESTIONS = [ { question: "How do you say 'thank you' in Spanish?", correct: 'gracias', options: ['hola', 'gracias', 'por favor', 'adiós'], }, { question: "How do you say 'good morning' in Spanish?", correct: 'buenos días', options: ['buenas noches', 'buenos días', 'hola', 'bien'], }, { question: "How do you say 'I don't understand' in Spanish?", correct: 'No entiendo', options: ['No entiendo', 'Me llamo', 'Necesito ayuda', 'Lo siento'], }, ] // ---------- Utilities ---------- const STORAGE_KEY = 'spanish_a1_progress_v1' const DEFAULT_PROGRESS = { points: 0, level: 1, total_correct: 0, total_attempts: 0, streak: 0, last_practice: null, } function loadProgress() { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return { ...DEFAULT_PROGRESS } const parsed = JSON.parse(raw) return { ...DEFAULT_PROGRESS, ...parsed } } catch (e) { console.error('loadProgress error', e) return { ...DEFAULT_PROGRESS } } } function saveProgress(p) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)) } catch (e) { console.error('saveProgress error', e) } } function addPoints(progress, pts) { progress.points += pts progress.level = 1 + Math.floor(progress.points / 100) progress.last_practice = new Date().toISOString() saveProgress(progress) } function normalizeAnswer(s) { if (!s) return '' return s .toLowerCase() .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .replace(/[^a-z0-9ñ¿¡ ]/g, '') .trim() } // ---------- UI Components ---------- function Header({ progress }) { return (
Spanish A1 — Learn & Practice
Points: {progress.points}
Level: {progress.level}
) } function Tabs({ tabs, current, setCurrent }) { return (
{tabs.map((t) => ( ))}
) } function Flashcards({ progress, updateGlobal }) { const [deck, setDeck] = useState(() => [...VOCAB].sort(() => Math.random() - 0.5)) const [idx, setIdx] = useState(0) const [showFront, setShowFront] = useState(true) useEffect(() => saveProgress(progress), [progress]) const entry = deck[idx % deck.length] function flip() { setShowFront((s) => !s) } function next() { setIdx((i) => i + 1) setShowFront(true) } function know() { addPoints(progress, 5) // small chance to remove if (Math.random() < 0.25) { setDeck((d) => d.filter((_, i) => i !== idx % d.length)) setIdx((i) => Math.max(0, i - 1)) } else { setIdx((i) => i + 1) } updateGlobal() } if (!entry) return
No flashcards
return (
{showFront ? entry.es : entry.en}
) } function Practice({ progress, updateGlobal }) { const [current, setCurrent] = useState(() => MC_QUESTIONS[Math.floor(Math.random() * MC_QUESTIONS.length)]) const [selected, setSelected] = useState(null) const [feedback, setFeedback] = useState('') useEffect(() => saveProgress(progress), [progress]) function newQuestion() { setCurrent(MC_QUESTIONS[Math.floor(Math.random() * MC_QUESTIONS.length)]) setSelected(null) setFeedback('') } function submit() { if (!selected) { setFeedback('Please choose an option') return } progress.total_attempts += 1 if (normalizeAnswer(selected) === normalizeAnswer(current.correct)) { setFeedback('Correct! +10 points') addPoints(progress, 10) progress.total_correct += 1 progress.streak += 1 } else { setFeedback(`Not quite. Correct: ${current.correct}`) progress.streak = 0 } saveProgress(progress) updateGlobal() setTimeout(newQuestion, 1100) } const options = useMemo(() => [...current.options].sort(() => Math.random() - 0.5), [current]) return (
{current.question}
{options.map((opt) => ( ))}
{feedback &&
{feedback}
}
) } function Conversation({ progress, updateGlobal }) { const pool = useMemo(() => [...PHRASES, ...VOCAB], []) const [current, setCurrent] = useState(() => pool[Math.floor(Math.random() * pool.length)]) const [answer, setAnswer] = useState('') const [feedback, setFeedback] = useState('') useEffect(() => saveProgress(progress), [progress]) function newPrompt() { setCurrent(pool[Math.floor(Math.random() * pool.length)]) setAnswer('') setFeedback('') } function check() { progress.total_attempts += 1 const normUser = normalizeAnswer(answer) const normTarget = normalizeAnswer(current.es) if (normUser === normTarget || normTarget.includes(normUser) || normUser.includes(normTarget)) { setFeedback('Good! +15 points') addPoints(progress, 15) progress.total_correct += 1 progress.streak += 1 saveProgress(progress) updateGlobal() setTimeout(newPrompt, 900) } else { setFeedback(`Not quite. Expected: ${current.es}`) progress.streak = 0 saveProgress(progress) updateGlobal() } } return (
Translate into Spanish:
{current.en}
setAnswer(e.target.value)} className="w-full p-2 border rounded" placeholder="Write your answer here" />
{feedback &&
{feedback}
}
) } function ProgressPanel({ progress, resetProgress, manualSaveKey }) { const attempts = progress.total_attempts || 0 const correct = progress.total_correct || 0 const accuracy = attempts > 0 ? ((correct / attempts) * 100).toFixed(1) + '%' : '0%' return (
Points: {progress.points}
Level: {progress.level}
Accuracy: {accuracy} ({correct}/{attempts})
Streak: {progress.streak}
Last practice: {progress.last_practice ? new Date(progress.last_practice).toLocaleString() : '—'}
Save key: {manualSaveKey}
) } // ---------- Main App ---------- export default function App() { const [progress, setProgress] = useState(() => loadProgress()) const [tab, setTab] = useState('Flashcards') useEffect(() => saveProgress(progress), [progress]) function updateGlobal() { setProgress(loadProgress()) } function resetProgress() { if (!confirm('Reset progress?')) return const p = { ...DEFAULT_PROGRESS } setProgress(p) saveProgress(p) } return (
A1 • Desktop-friendly • Local progress
{tab === 'Flashcards' && } {tab === 'Practice' && } {tab === 'Conversation' && } {tab === 'Progress' && }
) }