/*
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' &&
}
)
}