/* Auth — simple email+password login, persisted in localStorage. Not real backend security; this is a prototype. */ const AUTH_KEY = 'ba_aptis_auth_v1'; const USERS_KEY = 'ba_aptis_users_v1'; function getStoredUsers() { try { return JSON.parse(localStorage.getItem(USERS_KEY) || '{}'); } catch { return {}; } } function setStoredUsers(u) { localStorage.setItem(USERS_KEY, JSON.stringify(u)); } function getCurrentUser() { try { return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch { return null; } } function setCurrentUser(u) { if (u) localStorage.setItem(AUTH_KEY, JSON.stringify(u)); else localStorage.removeItem(AUTH_KEY); // Tell other tabs/components window.dispatchEvent(new CustomEvent('ba-auth-change', { detail: u })); } function registerUser({ name, email, password }) { const users = getStoredUsers(); if (users[email.toLowerCase()]) { throw new Error('Ya existe una cuenta con este email. Inicia sesión.'); } users[email.toLowerCase()] = { name, email, password, createdAt: Date.now() }; setStoredUsers(users); const u = { name, email, createdAt: users[email.toLowerCase()].createdAt }; setCurrentUser(u); return u; } function loginUser({ email, password }) { const users = getStoredUsers(); const rec = users[email.toLowerCase()]; if (!rec) throw new Error('No encontramos una cuenta con ese email.'); if (rec.password !== password) throw new Error('La contraseña no es correcta.'); const u = { name: rec.name, email: rec.email, createdAt: rec.createdAt }; setCurrentUser(u); return u; } function logoutUser() { setCurrentUser(null); } function useAuth() { const [user, setUser] = React.useState(() => getCurrentUser()); React.useEffect(() => { const h = (e) => setUser(e.detail || getCurrentUser()); window.addEventListener('ba-auth-change', h); window.addEventListener('storage', () => setUser(getCurrentUser())); return () => window.removeEventListener('ba-auth-change', h); }, []); return user; } // ---- Auth modal (login + register, switchable) ---- const AuthModal = ({ open, onClose, initialMode = 'login' }) => { const [mode, setMode] = React.useState(initialMode); const [name, setName] = React.useState(''); const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const [error, setError] = React.useState(''); React.useEffect(() => { if (open) { setError(''); setMode(initialMode); } }, [open, initialMode]); if (!open) return null; const submit = (e) => { e.preventDefault(); setError(''); try { if (mode === 'register') { if (!name.trim()) { setError('Por favor, escribe tu nombre.'); return; } if (password.length < 6) { setError('La contraseña debe tener al menos 6 caracteres.'); return; } registerUser({ name: name.trim(), email: email.trim(), password }); } else { loginUser({ email: email.trim(), password }); } onClose && onClose(); } catch (err) { setError(err.message || 'Ha ocurrido un error.'); } }; return (
e.stopPropagation()} style={{ background: '#fff', borderRadius: 18, width: '100%', maxWidth: 440, boxShadow: 'var(--shadow-lg)', padding: 32, position: 'relative', }}>
Bristol Academy
Aptis Exam Trainer

{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}

{mode === 'login' ? 'Entra para continuar tus simulacros y ver tus resultados.' : 'Crea tu cuenta para guardar progreso y enviar Writings a tu profesor.'}

{mode === 'register' && ( )} {error && (
{error}
)}
{mode === 'login' ? ( <>¿Todavía no tienes cuenta?{' '} ) : ( <>¿Ya tienes cuenta?{' '} )}
Al crear tu cuenta aceptas nuestros términos. Tus datos se usan únicamente para gestionar tu acceso a la plataforma.
); }; const authInputStyle = { padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, fontFamily: 'inherit', fontSize: 14.5, outline: 'none', width: '100%', }; // ---- Send Writing via email ---- // Uses Web3Forms — delivers emails directly to the teacher's inbox without // any activation step. We POST the form data through a hidden iframe to avoid // CORS issues from any origin. const TEACHER_EMAIL = 'examsbristolacademy@gmail.com'; const WEB3FORMS_ACCESS_KEY = '37943c7a-4d7f-418a-95fc-b264fd02859d'; const WEB3FORMS_ENDPOINT = 'https://api.web3forms.com/submit'; function sendWritingByEmail({ user, examNum, topic, parts, responses }) { return new Promise((resolve) => { // ---- Build plain-text body ---- const lines = []; lines.push(`Aptis General — Writing submission`); lines.push(`=================================`); lines.push(``); lines.push(`Alumno: ${user?.name || '(sin nombre)'}`); lines.push(`Email del alumno: ${user?.email || '(sin email)'}`); lines.push(`Examen: Exam ${examNum}`); if (topic) lines.push(`Tema: ${topic}`); lines.push(`Fecha: ${new Date().toLocaleString('es-ES')}`); lines.push(``); lines.push(`---------------------------------`); lines.push(``); parts.forEach((part, pi) => { lines.push(`>> ${part.label}`); lines.push(``); part.items.forEach((it, ii) => { const ans = responses[`${pi}-${ii}`] || '(sin respuesta)'; if (it.prompt) { lines.push(`PROMPT: ${it.prompt.replace(/\n/g, ' ').slice(0, 400)}`); } lines.push(`RESPUESTA:`); lines.push(ans); lines.push(``); }); lines.push(``); }); const body = lines.join('\n'); const subject = `[Aptis Trainer] Writing Exam ${examNum} — ${user?.name || user?.email || 'alumno'}`; // Try AJAX first (Web3Forms supports CORS) fetch(WEB3FORMS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ access_key: WEB3FORMS_ACCESS_KEY, subject: subject, from_name: user?.name || 'Aptis Trainer', replyto: user?.email || '', name: user?.name || '(sin nombre)', email: user?.email || '', examen: `Exam ${examNum}`, fecha: new Date().toLocaleString('es-ES'), message: body, }), }) .then(res => res.json()) .then(data => { if (data.success) { resolve({ ok: true, message: 'Email enviado correctamente.' }); } else { resolve({ ok: false, message: data.message || 'No se pudo enviar.' }); } }) .catch((err) => { resolve({ ok: false, message: 'Error de red: ' + (err.message || err) }); }); }); } window.useAuth = useAuth; window.getCurrentUser = getCurrentUser; window.AuthModal = AuthModal; window.logoutUser = logoutUser; // ---- Send Speaking via email ---- // Bundles audio recordings into a ZIP file (client-side using JSZip), // uploads the ZIP to file.io (free anonymous transfer service, 14-day expiry), // and emails the teacher a download link via Web3Forms. // Also triggers a local download of the ZIP as a backup for the student. function sendSpeakingByEmail({ user, examNum, tasks, recordings }) { return new Promise(async (resolve) => { if (typeof window.JSZip === 'undefined') { resolve({ ok: false, message: 'Falta la librería ZIP. Recarga la página.' }); return; } // ---- Build ZIP with recordings ---- const zip = new window.JSZip(); let totalBytes = 0; const recordingDescriptions = []; tasks.forEach((task, i) => { const rec = recordings[i]; if (rec && rec.blob) { const ext = (rec.mime || '').includes('mp4') ? 'm4a' : (rec.mime || '').includes('ogg') ? 'ogg' : (rec.mime || '').includes('mpeg') ? 'mp3' : 'webm'; // Build a clean filename from the task title: "Part 1 · Personal information — Q1" → "Part1_Q1" const partMatch = task.title.match(/Part\s+(\d+)/i); const partNum = partMatch ? `Part${partMatch[1]}` : `Task${i+1}`; const qMatch = task.title.match(/Q(\d+)/); const subPart = qMatch ? `_Q${qMatch[1]}` : task.title.toLowerCase().includes('combined') || task.title.toLowerCase().includes('opinion') ? '_Combined' : ''; const filename = `${partNum}${subPart}.${ext}`; zip.file(filename, rec.blob); totalBytes += rec.blob.size; recordingDescriptions.push(`${filename} · ${Math.round(rec.blob.size/1024)} KB · ${rec.duration}s`); } }); if (totalBytes === 0) { resolve({ ok: false, message: 'No hay grabaciones para enviar.' }); return; } // Add a metadata text file inside the ZIP const metadataLines = []; metadataLines.push(`Aptis General — Speaking submission`); metadataLines.push(`====================================`); metadataLines.push(``); metadataLines.push(`Student: ${user?.name || '(no name)'}`); metadataLines.push(`Email: ${user?.email || '(no email)'}`); metadataLines.push(`Exam: Exam ${examNum}`); metadataLines.push(`Date: ${new Date().toLocaleString('es-ES')}`); metadataLines.push(``); metadataLines.push(`Tasks:`); tasks.forEach((task, i) => { const rec = recordings[i]; metadataLines.push(``); metadataLines.push(`• ${task.title}`); metadataLines.push(` Prompt: ${task.prompt}`); metadataLines.push(` Time limit: ${task.timeLimit}s`); if (rec) { metadataLines.push(` Recorded: ${rec.duration}s · ${Math.round(rec.blob.size/1024)} KB`); } else { metadataLines.push(` Recorded: (not recorded)`); } }); zip.file('README.txt', metadataLines.join('\n')); // Generate ZIP blob let zipBlob; try { zipBlob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }); } catch (err) { resolve({ ok: false, message: 'Error creando ZIP: ' + (err.message || err) }); return; } const studentSlug = (user?.name || user?.email || 'alumno') .replace(/[^a-zA-Z0-9]/g, '_').slice(0, 30); const dateSlug = new Date().toISOString().slice(0, 10); const zipFilename = `Speaking_Exam${examNum}_${studentSlug}_${dateSlug}.zip`; // ---- Backup: trigger local download for the student ---- try { const downloadUrl = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = downloadUrl; link.download = zipFilename; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000); } catch {} // ---- Upload ZIP to file.io ---- let downloadLink = null; let uploadError = null; try { const fd = new FormData(); fd.append('file', zipBlob, zipFilename); const res = await fetch('https://file.io/?expires=14d&maxDownloads=20', { method: 'POST', body: fd, }); const data = await res.json(); if (data && data.success && data.link) { downloadLink = data.link; } else { uploadError = data.message || 'No se pudo subir el ZIP a file.io.'; } } catch (err) { uploadError = err.message || 'Error de red al subir el ZIP.'; } // ---- Send email via Web3Forms with the download link (or fallback message) ---- const emailLines = []; emailLines.push(`Aptis General — Speaking submission`); emailLines.push(``); emailLines.push(`Alumno: ${user?.name || '(sin nombre)'}`); emailLines.push(`Email del alumno: ${user?.email || '(sin email)'}`); emailLines.push(`Examen: Exam ${examNum}`); emailLines.push(`Fecha: ${new Date().toLocaleString('es-ES')}`); emailLines.push(`Archivo ZIP: ${zipFilename}`); emailLines.push(`Tamaño: ${Math.round(zipBlob.size/1024)} KB`); emailLines.push(``); if (downloadLink) { emailLines.push(`>>> DESCARGAR AUDIOS <<<`); emailLines.push(`Enlace: ${downloadLink}`); emailLines.push(`(válido 14 días, hasta 20 descargas)`); } else { emailLines.push(`⚠ La subida automática falló: ${uploadError || 'desconocido'}`); emailLines.push(`El alumno tiene una copia local del ZIP y debería reenviártelo manualmente.`); } emailLines.push(``); emailLines.push(`---------------------------------`); emailLines.push(``); emailLines.push(`Grabaciones incluidas:`); recordingDescriptions.forEach(d => emailLines.push(` • ${d}`)); emailLines.push(``); emailLines.push(`---------------------------------`); emailLines.push(``); tasks.forEach((task, i) => { const rec = recordings[i]; emailLines.push(`>> ${task.title}`); emailLines.push(`Prompt: ${task.prompt}`); emailLines.push(`Time limit: ${task.timeLimit}s`); if (rec) emailLines.push(`Recorded: ${rec.duration}s · ${Math.round(rec.blob.size/1024)} KB`); else emailLines.push(`Recorded: (sin grabación)`); emailLines.push(``); }); const body = emailLines.join('\n'); const subject = `[Aptis Trainer] Speaking Exam ${examNum} — ${user?.name || user?.email || 'alumno'}`; fetch(WEB3FORMS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ access_key: WEB3FORMS_ACCESS_KEY, subject, from_name: user?.name || 'Aptis Trainer', replyto: user?.email || '', name: user?.name || '(sin nombre)', email: user?.email || '', examen: `Exam ${examNum}`, fecha: new Date().toLocaleString('es-ES'), zip_filename: zipFilename, download_link: downloadLink || '(no link — subida fallida)', message: body, }), }) .then(res => res.json()) .then(data => { if (data.success) { if (downloadLink) { resolve({ ok: true, message: 'Email enviado con enlace de descarga. El ZIP también se ha guardado en tu ordenador como copia.' }); } else { resolve({ ok: true, message: 'Email enviado, pero la subida automática del ZIP falló. El ZIP está en tu carpeta de Descargas — envíalo manualmente al profesor.' }); } } else { resolve({ ok: false, message: data.message || 'No se pudo enviar el email.' }); } }) .catch(err => { resolve({ ok: false, message: 'Error de red enviando email: ' + (err.message || err) }); }); }); } window.sendSpeakingByEmail = sendSpeakingByEmail; // ---- mailto fallback — opens user's email client with everything pre-filled ---- function buildMailtoFallback({ user, examNum, topic, parts, responses }) { const lines = []; lines.push(`Aptis General — Writing submission`); lines.push(``); lines.push(`Alumno: ${user?.name || '(sin nombre)'}`); lines.push(`Email del alumno: ${user?.email || '(sin email)'}`); lines.push(`Examen: Exam ${examNum}`); if (topic) lines.push(`Tema: ${topic}`); lines.push(`Fecha: ${new Date().toLocaleString('es-ES')}`); lines.push(``); lines.push(`---------------------------------`); lines.push(``); parts.forEach((part, pi) => { lines.push(`>> ${part.label}`); lines.push(``); part.items.forEach((it, ii) => { const ans = responses[`${pi}-${ii}`] || '(sin respuesta)'; const wc = (ans || '').trim().split(/\s+/).filter(Boolean).length; if (it.prompt) { lines.push(`PROMPT: ${it.prompt.replace(/\n/g, ' ').slice(0, 400)}`); } if (it.minWords && it.maxWords) { lines.push(`RECOMENDADO: ${it.minWords}–${it.maxWords} palabras · ESCRITO: ${wc}`); if (wc < it.minWords && window.computeWordPenalty) { const penalty = window.computeWordPenalty(wc, it.minWords); if (penalty > 0) lines.push(`⚠ Por debajo del mínimo recomendado · penalización estimada: −${penalty}%`); } if (wc > it.maxWords) { lines.push(`⚠ Por encima del máximo recomendado.`); } } lines.push(`RESPUESTA:`); lines.push(ans); lines.push(``); }); lines.push(``); }); const body = lines.join('\n'); const subject = `[Aptis Trainer] Writing Exam ${examNum} — ${user?.name || user?.email || 'alumno'}`; return `mailto:${TEACHER_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } window.buildMailtoFallback = buildMailtoFallback; window.TEACHER_EMAIL = TEACHER_EMAIL;