/* 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 === 'login' ? (
<>¿Todavía no tienes cuenta?{' '}
{ setMode('register'); setError(''); }}
style={{ background:'transparent', border:'none', color: 'var(--accent-strong)',
fontWeight: 700, cursor: 'pointer' }}>
Crear cuenta
>
) : (
<>¿Ya tienes cuenta?{' '}
{ setMode('login'); setError(''); }}
style={{ background:'transparent', border:'none', color: 'var(--accent-strong)',
fontWeight: 700, cursor: 'pointer' }}>
Iniciar sesión
>
)}
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;