Listen to each audio, click the slot you want it to go in. When done, click SAVE — it rebuilds your 5 mocks in Firestore. Audio files stay where they are in Supabase; only the mock structure is rebuilt.
DASHBOARD
MATRIX
'
+ '
' + l.n + '
'
+ '
'
+ '
LESSON ' + (l.n < 10 ? '0' : '') + l.n + '
'
+ '
' + l.title + '
'
+ '
→
';
}).join('');
}
// A lesson (inside the iframe) asks to be closed via its Back button.
window.addEventListener('message', function(ev){
if(ev && ev.data === 'matrix-close-lesson') ssCloseLesson();
});
function ssSoon(name){
if(typeof showToast === 'function') showToast(name + ' lessons are coming soon');
else alert(name + ' lessons are coming soon');
}
function navTo(id, skipHistory){
if(typeof closeSidebar==='function') closeSidebar();
document.querySelectorAll('.page').forEach(p=>{p.style.display='none';p.classList.remove('active');});
const pg=document.getElementById(id);
if(!pg)return;
pg.style.display='flex';pg.classList.add('active');
window.scrollTo(0,0);
// Show or hide the global sidebar toggle based on the page.
// Hide during exams, admin panels, expired/feedback flows so it doesn't
// overlap floating exam controls or admin UI.
const tg = document.getElementById('sidebarToggle');
if(tg){
const hidePages = ['pg-speaking','pg-admin-login','pg-admin','pg-expired','pg-feedback','pg-ss-lesson'];
tg.style.display = hidePages.includes(id) ? 'none' : 'flex';
}
if(id==='pg-about') renderAboutPage();
if(id==='pg-contact') renderContactPage();
if(id==='pg-privacy') renderPrivacyPage();
if(id==='pg-mocks' && typeof renderMockList==='function') renderMockList();
if(id==='pg-folders' && typeof renderFolderList==='function') renderFolderList();
if(id==='pg-ss-writing-t1' && typeof renderSsTask1==='function') renderSsTask1();
if(id==='pg-home') updateAiAssessCard();
// Re-attach password toggles when entering admin pages (in case inputs were
// re-rendered or initial DOMContentLoaded missed them)
if(id==='pg-admin-login' || id==='pg-admin'){
if(typeof attachAllPasswordToggles==='function') attachAllPasswordToggles();
}
// Push a new browser history entry — unless this call came FROM popstate
if(!skipHistory){
try{ history.pushState({page:id}, '', '#'+id.replace('pg-','')); }catch(e){}
}
}
// Handle browser back/forward button
window.addEventListener('popstate', function(e){
// Mid-exam: back button aborts the exam instead of leaving
if(typeof isExamPage==='function' && isExamPage()){
// Re-push so they stay on exam history slot
try{ history.pushState({page:'pg-speaking'}, '', '#speaking'); }catch(ex){}
if(typeof abortSpeaking==='function') abortSpeaking();
return;
}
// On done screen: back button exits fullscreen and navigates to sections
if(typeof isDoneScreen==='function' && isDoneScreen()){
exitDoneScreen();
return;
}
// Figure out destination page. If event.state is present (normal back), use it;
// otherwise default to home.
const target = (e.state && e.state.page) ? e.state.page : 'pg-home';
navTo(target, true); // skipHistory=true because browser already popped
});
// Initialise state stack with home — makes first back a no-op within the site
try{
history.replaceState({page:'pg-home'}, '', window.location.pathname + window.location.search);
}catch(e){}
/* ════════════════════════════════════════
MOCK LIST
════════════════════════════════════════ */
/* ════════════════════════════════════════
PUBLIC ABOUT + CONTACT PAGES
════════════════════════════════════════ */
function renderAboutPage(){
const a=DB.about||{};
const el=document.getElementById('aboutContent');
if(!el)return;
const imgs=(a.images||[]).filter(Boolean);
const videoId=a.videoUrl?extractYTId(a.videoUrl):'';
el.innerHTML=`
${a.title?`
':''}
`;
}
function renderPrivacyPage(){
const el=document.getElementById('privacyContent');
if(!el)return;
const body = (DB.privacyPolicy && DB.privacyPolicy.body) || _defaultPrivacyPolicy();
const updated = (DB.privacyPolicy && DB.privacyPolicy.updatedAt) ? new Date(DB.privacyPolicy.updatedAt).toLocaleDateString() : '';
el.innerHTML = `
${escHtml(body)}
${updated?`
LAST UPDATED: ${escHtml(updated)}
`:''}
`;
}
function _defaultPrivacyPolicy(){
return `MATRIX MOCK — PRIVACY POLICY
This document explains what data we collect when you take a speaking exam on matrixmock.uz, why we collect it, and how we handle it.
1. WHAT WE COLLECT
- For mocks with AI feedback: a recording of your voice during the speaking exam.
- Your Telegram handle, only if AI feedback fails and you choose to receive it via Telegram.
- General usage statistics: which mocks were taken, when. No personal identifiers.
2. WHY WE COLLECT IT
- The voice recording is sent to an automated AI service for transcription and assessment in order to generate your feedback.
- Your Telegram handle is used only to deliver feedback if our system could not show it on the website.
3. HOW LONG WE KEEP IT
- Voice recordings are deleted from our servers immediately after AI feedback is delivered.
- If you do not receive feedback within 7 days, recordings are automatically deleted.
- Statistics are retained anonymously and indefinitely.
4. WHO HAS ACCESS
- Our automated AI service receives your audio briefly for processing only. We do not retain it.
- Matrix Mock administrators can re-trigger AI assessment for unprocessed exams but cannot listen to the audio directly.
- Your data is never sold or shared with third parties beyond what is needed to generate feedback.
5. YOUR RIGHTS
- You may decline to allow microphone access. In that case, you can take exams without AI feedback.
- You may contact us at any time on Telegram to request that we stop processing your data.
6. AGE
- Users under 18 must have parental or guardian permission before taking exams that involve voice recording.
7. CONTACT
- For privacy questions, contact us via the Telegram link in the Contacts section.
By using this site and accepting microphone permissions, you agree to the terms above.`;
}
function renderContactPage(){
const c=DB.contacts||{telegrams:[],phones:[],emails:[]};
const el=document.getElementById('contactContent');
if(!el)return;
const card=(iconClass,label,val,href)=>`
';
}
function extractYTId(url){
// Handles: youtu.be/ID, youtube.com/watch?v=ID, /embed/ID, /v/ID, /shorts/ID
const m=String(url||'').match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{6,15})/);
return m?m[1]:'';
}
// Helper: does a given mock include AI feedback?
// Returns true for "free_ai" and "paid" access types, false for "free".
function mockHasAI(mock){
if(!mock) return false;
const acc = mock.accessType || (mock.paid?'paid':'free');
return acc === 'free_ai' || acc === 'paid';
}
// _activeTrack = which exam family; _activeFolderId = which folder's mocks to
// show in the mock list (null = the "Ungrouped" pseudo-folder).
window._activeTrack = window._activeTrack || 'multilevel';
window._activeFolderId = window._activeFolderId || null;
// Called from the landing-page track cards → show the folder screen.
function selectTrack(track){
window._activeTrack = (track==='ielts') ? 'ielts' : 'multilevel';
renderFolderList();
navTo('pg-folders');
}
// Folder screen: shows each folder for the track as a card, plus an automatic
// "Ungrouped" card if there are active ungrouped mocks. Tapping a card opens
// the mock list filtered to that folder.
function renderFolderList(){
const track = window._activeTrack || 'multilevel';
const grid = document.getElementById('folderListGrid');
const titleEl = document.getElementById('folderListTitle');
if(titleEl) titleEl.textContent = (track==='ielts'?'IELTS':'MULTILEVEL')+' FOLDERS';
if(!grid) return;
grid.innerHTML = '';
const activeMocks = DB.mocks.filter(m => m.active && (m.type||'multilevel')===track);
const folders = (DB.folders||[]).filter(f => f.track===track).sort((a,b)=>a.order-b.order);
// Build folder cards (only those with at least one active mock)
const cards = [];
folders.forEach(f => {
const count = activeMocks.filter(m => (m.folders||[]).includes(f.id)).length;
if(count === 0) return; // hide empty folders from students
cards.push({ id:f.id, name:f.name, desc:f.description||'', count });
});
// Ungrouped pseudo-folder
const ungroupedCount = activeMocks.filter(m => {
const fs = (m.folders||[]).filter(fid => folders.some(f=>f.id===fid));
return fs.length === 0;
}).length;
if(ungroupedCount > 0){
cards.push({ id:null, name:'Ungrouped', desc:'Mocks not in any folder', count:ungroupedCount });
}
if(cards.length === 0){
grid.innerHTML = '
No mocks available yet.
';
return;
}
// If there's exactly one folder/group, skip straight into it for less tapping.
// Remember that we auto-skipped, so the mock-list Back button goes HOME instead
// of bouncing back here (which would just re-trigger this auto-skip).
if(cards.length === 1){
window._folderAutoSkipped = true;
openFolder(cards[0].id);
return;
}
window._folderAutoSkipped = false;
cards.forEach((c, i)=>{
const d = document.createElement('div');
d.className = 'mock-card folder-card';
d.innerHTML = `
OPEN →`;
d.onclick = () => openFolder(c.id);
grid.appendChild(d);
});
}
// Open a folder → show the mock list filtered to it. folderId null = Ungrouped.
function openFolder(folderId){
window._activeFolderId = folderId;
renderMockList();
navTo('pg-mocks');
}
// Mock-list back button: return to the folder screen — unless we auto-skipped
// a single folder, in which case go straight home (no folder screen to show).
function backFromMockList(){
if(window._folderAutoSkipped){
navTo('pg-home');
return;
}
renderFolderList();
navTo('pg-folders');
}
/* ════════════════════════════════════════
SPEAKING AI ASSESSMENT (self-entered Q + A)
Student picks track + part, types their own question, optionally adds a
picture (Part 1.2 mandatory / Part 2 optional), records an answer, and the
AI assesses that single answer. Paid/free per track (admin-controlled);
promocode is asked at the moment Record is pressed. No certificate.
════════════════════════════════════════ */
let _aiaState = { track:'multilevel', part:'', question:'', imgData:'', imgName:'', recording:false };
let _aiaMediaRecorder = null, _aiaChunks = [], _aiaStream = null, _aiaMime = '', _aiaStartTs = 0;
function openAiAssess(){
if(!(DB.settings && DB.settings.aiAssess && DB.settings.aiAssess.enabled)){
showToast('This section is currently unavailable.');
return;
}
_aiaState = { track:'multilevel', part:'', question:'', imgData:'', imgName:'', recording:false };
navTo('pg-aiassess');
renderAiAssessInput();
}
// Parts available per track. Multilevel: 1.1/1.2/2/3. IELTS: 1/2/3.
function aiaPartsFor(track){
return track==='ielts'
? [{v:'Part 1',l:'Part 1 — Interview'},{v:'Part 2',l:'Part 2 — Long turn (cue card)'},{v:'Part 3',l:'Part 3 — Discussion'}]
: [{v:'Part 1.1',l:'Part 1.1 — About yourself'},{v:'Part 1.2',l:'Part 1.2 — Picture comparison'},{v:'Part 2',l:'Part 2 — Cue card'},{v:'Part 3',l:'Part 3 — Topic / debate'}];
}
// Does this track+part need/allow a picture? mandatory for ML 1.2; optional for ML 2 & IELTS 2.
function aiaImageMode(track, part){
if(track==='multilevel' && part==='Part 1.2') return 'mandatory';
if(track==='multilevel' && part==='Part 2') return 'optional';
if(track==='ielts' && part==='Part 2') return 'optional';
return 'none';
}
function renderAiAssessInput(){
const c = document.getElementById('aiAssessContent');
if(!c) return;
// Each new assessment must claim a promocode again (one AI use per assessment).
// Reset the unlock + recording flags whenever we return to the input screen.
_aiaState._unlocked = false;
_aiaState.recording = false;
const track = _aiaState.track;
const parts = aiaPartsFor(track);
// Preserve current part if still valid for the track, else blank.
if(!parts.some(p=>p.v===_aiaState.part)) _aiaState.part = '';
const imgMode = aiaImageMode(track, _aiaState.part);
c.innerHTML = `
Type your own speaking question, pick which part it belongs to, then record your answer. The AI assesses your single answer against the official criteria and gives you a level and detailed feedback. No certificate is issued here.
Misspelled words are underlined by your browser. Fix them for the most accurate question.
🖼
${_aiaState.imgData?'✓ '+escHtml(_aiaState.imgName||'Image selected'):'CLICK TO UPLOAD PICTURE'}
${_aiaState.imgData?``:''}
`;
}
function aiaOnTrackChange(v){
_aiaState.track = (v==='ielts') ? 'ielts' : 'multilevel';
renderAiAssessInput();
}
function aiaOnPartChange(v){
_aiaState.part = v;
// Re-render so the picture block shows/hides for the chosen part.
renderAiAssessInput();
}
function aiaHandleImg(e){
const f = e.target.files && e.target.files[0];
if(!f) return;
if(f.size > 8*1024*1024){ showToast('Image too large (max 8MB)'); return; }
const reader = new FileReader();
reader.onload = () => { _aiaState.imgData = reader.result; _aiaState.imgName = f.name; renderAiAssessInput(); };
reader.readAsDataURL(f);
}
// Validation before recording is allowed.
function aiaValidate(){
if(!_aiaState.part){ showToast('Please choose a part'); return false; }
const q = (_aiaState.question||'').trim();
if(q.split(/\s+/).filter(Boolean).length < 3){
showToast('Please type a fuller question (at least 3 words)');
const ta = document.getElementById('aia_question');
if(ta){ ta.style.borderColor = 'var(--danger)'; ta.focus(); }
return false;
}
const imgMode = aiaImageMode(_aiaState.track, _aiaState.part);
if(imgMode==='mandatory' && !_aiaState.imgData){
showToast('This part requires a picture. Please upload one.');
return false;
}
return true;
}
// Record button — validates, asks promocode if the track is paid, then records.
async function aiaToggleRecord(){
if(_aiaState.recording){ aiaStopRecording(); return; }
if(!aiaValidate()) return;
const track = _aiaState.track;
const paid = !!(DB.settings && DB.settings.aiAssess &&
(track==='ielts' ? DB.settings.aiAssess.paidIelts : DB.settings.aiAssess.paidMultilevel));
if(paid && !_aiaState._unlocked){
// Ask for a promocode before recording. Reuse the existing promocode system.
aiaPromptPromocode();
return;
}
aiaStartRecording();
}
// Promocode gate for AI Assessment. Uses the same cloud promocode claim the
// rest of the site uses (_fsPromoClaim with type 'ai'). On success, unlock +
// start recording.
function aiaPromptPromocode(){
openModal(`
🔑 ENTER PROMOCODE
Speaking AI Assessment requires a promocode. Enter yours to unlock and start recording.
`, false);
setTimeout(()=>{ const el=document.getElementById('aia_promo'); if(el) el.focus(); }, 100);
}
async function aiaSubmitPromo(){
const el = document.getElementById('aia_promo');
const stat = document.getElementById('aia_promo_status');
const code = el ? el.value.trim().toUpperCase() : '';
if(!code){ if(stat) stat.innerHTML='Enter a promocode'; return; }
if(!window._fsPromoClaim){
if(stat) stat.innerHTML='Cloud not connected. Try again in a moment.';
return;
}
if(stat) stat.innerHTML='Verifying…';
try{
const result = await window._fsPromoClaim(code, 'ai');
if(stat) stat.innerHTML='✓ Accepted. '+result.remaining.ai+' AI uses left.';
_aiaState._unlocked = true;
setTimeout(()=>{ closeModal(); aiaStartRecording(); }, 700);
}catch(err){
if(stat) stat.innerHTML=''+escHtml(String(err.message||err))+'';
}
}
async function aiaStartRecording(){
// Acquire mic
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true, noiseSuppression:true}});
}catch(e){
showToast('Microphone access is required. Please allow it and try again.');
return;
}
_aiaStream = stream;
// Pick a supported mime; m4a for Safari/iOS, webm otherwise.
const candidates = ['audio/webm;codecs=opus','audio/webm','audio/mp4','audio/mpeg'];
_aiaMime = '';
for(const m of candidates){ try{ if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)){ _aiaMime=m; break; } }catch(e){} }
try{
_aiaMediaRecorder = new MediaRecorder(stream, _aiaMime?{mimeType:_aiaMime, audioBitsPerSecond:32000}:undefined);
}catch(e){
_aiaMediaRecorder = new MediaRecorder(stream);
}
_aiaChunks = [];
_aiaMediaRecorder.ondataavailable = e => { if(e.data && e.data.size) _aiaChunks.push(e.data); };
_aiaMediaRecorder.onstop = aiaOnRecordingStopped;
_aiaMediaRecorder.start();
_aiaState.recording = true;
_aiaStartTs = Date.now();
// Swap button to STOP + show a recording indicator.
const btn = document.getElementById('aia_record_btn');
if(btn){
btn.innerHTML = ' STOP RECORDING';
btn.style.background = '#e24b4a';
btn.style.borderColor = '#e24b4a';
}
const hint = document.getElementById('aia_record_hint');
if(hint) hint.textContent = 'Recording… speak your answer, then press Stop.';
}
function aiaStopRecording(){
if(_aiaMediaRecorder && _aiaMediaRecorder.state !== 'inactive'){
try{ _aiaMediaRecorder.stop(); }catch(e){}
}
_aiaState.recording = false;
}
async function aiaOnRecordingStopped(){
// Release mic
if(_aiaStream){ try{ _aiaStream.getTracks().forEach(t=>t.stop()); }catch(e){} _aiaStream=null; }
const durationSec = Math.round((Date.now() - _aiaStartTs)/1000);
const ext = (_aiaMime && _aiaMime.includes('mp4')) ? 'm4a' : 'webm';
const blob = new Blob(_aiaChunks, {type: _aiaMime || 'audio/webm'});
_aiaChunks = [];
if(!blob.size){
showToast('No audio captured. Please try recording again.');
aiaResetRecordButton();
return;
}
// Show a processing screen
aiaShowProcessing();
// Upload audio to Supabase so Deepgram can fetch it.
const assessId = 'aia_'+Date.now()+'_'+Math.random().toString(36).slice(2,7);
const path = 'aiassess/'+assessId+'/answer.'+ext;
const file = new File([blob], 'answer.'+ext, {type: blob.type});
let audioUrl = null;
try{
audioUrl = await window._uploadToStorage(file, path);
}catch(e){ audioUrl = null; }
if(!audioUrl){
aiaShowError('Could not upload your recording. Check your connection and try again.');
return;
}
// Upload image if present (so the AI can see it).
let imgUrl = '';
if(_aiaState.imgData){
try{
const imgBlob = await (await fetch(_aiaState.imgData)).blob();
const imgExt = (imgBlob.type && imgBlob.type.includes('png')) ? 'png' : 'jpg';
const imgFile = new File([imgBlob], 'pic.'+imgExt, {type: imgBlob.type});
imgUrl = await window._uploadToStorage(imgFile, 'aiassess/'+assessId+'/pic.'+imgExt) || '';
}catch(e){ imgUrl = ''; }
}
// Build the single-question payload. The backend recognises mode:'single'.
const payload = {
examId: assessId,
examType: _aiaState.track, // 'multilevel' | 'ielts'
mode: 'single', // tells assess.js to grade one question
singlePart: _aiaState.part, // e.g. 'Part 1.2' or 'Part 3'
mockName: 'AI Assessment',
questions: [{
qNum: 1,
partLabel: _aiaState.part,
examType: _aiaState.track,
text: (_aiaState.question||'').trim(),
imgUrl: imgUrl || '',
recordingUrl: audioUrl,
hasRecording: true
}]
};
try{
const res = await fetch('/.netlify/functions/assess', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if(data && data.ok && data.feedback){
window._aiaLastFeedback = data.feedback;
renderAiAssessFeedback(data.feedback);
} else {
aiaShowError((data && data.reason) ? ('Assessment failed: '+data.reason) : 'Assessment failed. Please try again.');
}
}catch(e){
aiaShowError('Network error during assessment. Please try again.');
}
}
function aiaResetRecordButton(){
const btn = document.getElementById('aia_record_btn');
if(btn){
btn.innerHTML = ' START RECORDING';
btn.style.background = '';
btn.style.borderColor = '';
}
const hint = document.getElementById('aia_record_hint');
if(hint) hint.textContent = '';
}
function aiaShowProcessing(){
const c = document.getElementById('aiAssessContent');
if(!c) return;
c.innerHTML = `
${Array(10).fill('').join('')}
ASSESSING YOUR ANSWER
Transcribing and grading — this takes a few seconds…
`;
}
function aiaShowError(msg){
const c = document.getElementById('aiAssessContent');
if(!c) return;
c.innerHTML = `
${escHtml(msg)}
`;
}
// Render single-question feedback. Reuses the existing IELTS / Multilevel
// per-question renderers, adds a compact score header, an ad slot, and Retry.
function renderAiAssessFeedback(fb){
navTo('pg-aiassess-fb');
const el = document.getElementById('aiAssessFbContent');
if(!el) return;
const isIelts = (_aiaState.track === 'ielts') || (typeof fb.overall?.band_score === 'number');
const overall = fb.overall || {};
let html = '';
if(isIelts){
const band = (typeof overall.band_score === 'number') ? overall.band_score : 0;
const fmt = b => Number.isInteger(b) ? b+'.0' : ''+b;
const ss = overall.subskills || {};
html += `
${badgeText}`;
d.onclick = (acc==='paid') ? () => showPromoEntryModal(m, 'paid') : () => openMockSections(m);
grid.appendChild(d);
});
}
function openMockSections(mock){
window._activeMock=mock;
// Reset per-mock state — without this, flags from a previous mock can leak
// into the new one (e.g. _promoAsked stays true → modal gets skipped).
// We only reset if this is a DIFFERENT mock than the one currently active.
if(window._lastSelectedMockId !== mock.id){
window._promoAsked = false;
window._aiEnabledForExam = false;
window._lastSelectedMockId = mock.id;
}
document.getElementById('sectionTitle').textContent=mock.name.toUpperCase();
navTo('pg-sections');
}
function normalizeTg(v){
v=(v||'').trim();
if(!v) return 'https://t.me/Sunnatulla_H';
if(/^@/.test(v)) return 'https://t.me/'+v.slice(1);
if(/^t\.me\//i.test(v)) return 'https://'+v;
if(/^https?:\/\//i.test(v)) return v;
if(/^[a-zA-Z0-9_]{3,}$/.test(v)) return 'https://t.me/'+v;
return v; // whatever was saved — let it be
}
// Show promo code entry modal. `purpose` is 'paid' (paid mock) or 'ai' (AI add-on on a free mock).
function showPromoEntryModal(mock, purpose){
const tg = 'https://t.me/Sunnatulla_H';
const purposeText = purpose === 'paid'
? `${escHtml(mock.name)} is a paid mock. Enter your promocode to unlock it.`
: `Want detailed AI feedback on your speech (grammar, vocabulary, fluency, pronunciation)? Enter your promocode to enable AI assessment for this exam, or continue without AI.`;
const cancelLabel = purpose === 'ai' ? 'Continue without AI' : 'Cancel';
// Cancel action: for AI add-on, continue with no AI; for paid, just close
const cancelAction = purpose === 'ai'
? `closeModal();window._aiEnabledForExam=false;window._promoAsked=true;startSpeaking();`
: `closeModal()`;
const aiPrice = mock.aiPrice || 5000;
const priceLine = purpose === 'ai'
? `
AI assessment costs ${aiPrice.toLocaleString()} soum. Contact admin on Telegram to get a promocode.
`
: `
Don't have a code? Contact admin on Telegram to purchase one.
`;
window._pendingMock = mock;
window._pendingPurpose = purpose;
// Track this mock as "selected" so openMockSections won't reset our state
// after the user successfully enters their code.
window._lastSelectedMockId = mock.id;
openModal(`
`;
}
const help=document.getElementById('micHelpInstructions');
if(help) help.innerHTML = instructions;
navTo('pg-michelp');
});
}
function consentSkipAI(){
// Student chose to take exam without AI — clean up any previous mic state
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
window._aiEnabledForExam = false;
const mock = window._activeMock;
if(!mock){ navTo('pg-sections'); return; }
unlockAudio();
requestFullscreen();
navTo('pg-speaking');
preloadExamMedia(mock).finally(()=>beginExamFlow());
}
function consentCancel(){
// Stop any captured mic stream and return to sections
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
window._aiEnabledForExam = false;
navTo('pg-sections');
}
/* Collect every image/audio URL used in this mock, plus part-intro audios.
Fetch them all in parallel so the browser caches them. Resolves regardless
of individual failures — exam must start even if some assets fail. */
function preloadExamMedia(mock){
const stage=document.getElementById('speakStage');
if(stage) stage.innerHTML=`
LOADING EXAM
0%
`;
const urls=new Set();
const Q=mock.questions||{};
const collect = arr => (arr||[]).forEach(q=>{
if(!q) return;
if(q.audio) urls.add(q.audio);
if(q.img) urls.add(q.img);
if(q.img2) urls.add(q.img2);
});
collect(Q.intro); collect(Q.part1); collect(Q.part2); collect(Q.part3);
// Part intro audios
if(DB.intros) Object.values(DB.intros).forEach(it=>{ if(it&&it.audio) urls.add(it.audio); });
const total = urls.size;
if(total===0) return Promise.resolve();
let done=0;
const update = () => {
const pct=Math.round(done/total*100);
const fill=document.getElementById('preloadBarFill');
const pctEl=document.getElementById('preloadPct');
if(fill) fill.style.width=pct+'%';
if(pctEl) pctEl.textContent=pct+'% ('+done+'/'+total+')';
};
update();
// Cache: url -> Blob URL for fast playback/display (zero-network at use time)
// Both audio AND images go through this — slow connections need actual blob
// caching, not just a fired-off browser fetch that may timeout before download
// completes.
window._audioBlobCache = window._audioBlobCache || {};
window._imageBlobCache = window._imageBlobCache || {};
const loadOne = (url) => new Promise(resolve=>{
// Images get a longer timeout because some students are on 2G/3G and image
// sizes can be 1-3MB. Audio kept at 12s because audio files are smaller.
const isImage = /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(url);
const TIMEOUT_MS = isImage ? 25000 : 12000;
let settled = false;
const timer = setTimeout(()=>{
if(settled) return;
settled = true;
done++; update(); resolve();
}, TIMEOUT_MS);
const finish = ()=>{
if(settled) return;
settled = true;
clearTimeout(timer); done++; update(); resolve();
};
if(isImage){
// Already cached?
if(window._imageBlobCache[url]){ finish(); return; }
// Fetch image as blob → blob URL — actual cache, not just hint to browser
fetch(url, { cache:'force-cache' })
.then(r=>{
if(!r.ok) throw new Error('HTTP '+r.status);
return r.blob();
})
.then(blob=>{
try{ window._imageBlobCache[url] = URL.createObjectURL(blob); }catch(e){}
finish();
})
.catch(()=>{
// Fallback: try a plain Image() load. Still better than skipping —
// browser may have it in HTTP cache for the tag later.
const img = new Image();
img.onload = finish;
img.onerror = finish;
img.src = url;
});
} else {
// Audio
if(window._audioBlobCache[url]){ finish(); return; }
fetch(url, { cache:'force-cache' })
.then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.blob(); })
.then(blob=>{
try{ window._audioBlobCache[url] = URL.createObjectURL(blob); }catch(e){}
finish();
})
.catch(finish);
}
});
// Run up to 4 in parallel (phones struggle with many concurrent fetches)
return new Promise(resolve=>{
const queue=Array.from(urls);
let active=0;
const next = () => {
if(queue.length===0 && active===0){ resolve(); return; }
while(active<4 && queue.length>0){
active++;
loadOne(queue.shift()).then(()=>{ active--; next(); });
}
};
next();
});
}
// Registry of every Audio + Oscillator created during the exam.
// abortSpeaking walks these lists and hard-stops each one — this is the only
// reliable way to guarantee no sound continues after the user exits the exam.
function _registerExamAudio(el){
if(!SS._allAudios) SS._allAudios=[];
SS._allAudios.push(el);
return el;
}
function _registerExamOsc(osc){
if(!SS._allOscs) SS._allOscs=[];
SS._allOscs.push(osc);
return osc;
}
function abortSpeaking(){
// Mark aborted so any in-flight async callback knows to bail out.
// CRITICAL: this flag must STAY TRUE until a new exam explicitly starts.
// Previously we did SS={} at the end, which cleared _aborted back to undefined —
// leftover timers would then read !SS._aborted as truthy and happily restart audio.
SS._aborted = true;
clearTimeout(SS.timer);
clearInterval(SS.countInt);
clearInterval(SS.prepInt);
// Hard-stop every audio element ever created in this session
(SS._allAudios||[]).forEach(au=>{
try{
au.pause();
au.onended=null; au.onerror=null; au.oncanplay=null;
au.onloadeddata=null; au.onloadedmetadata=null; au.onplaying=null;
au.src=''; au.removeAttribute('src'); au.load();
}catch(e){}
});
// Also kill the currently-active audio reference
_stopCurrentAudio();
// Hard-stop every oscillator (beep) still running
(SS._allOscs||[]).forEach(osc=>{
try{ osc.onended=null; osc.stop(); osc.disconnect(); }catch(e){}
});
// Safety net for any stray browser TTS from old sessions
try{ window.speechSynthesis && window.speechSynthesis.cancel(); }catch(e){}
// Stop microphone recording and release the mic — important when student exits mid-exam
try{ if(typeof ExamRecorder!=='undefined') ExamRecorder.abort(); }catch(e){}
// Also stop the global mic stream if the recorder didn't grab it
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
// Free blob URLs from the preload cache to avoid memory leaks
if(window._audioBlobCache){
Object.values(window._audioBlobCache).forEach(u=>{
if(u && u.startsWith('blob:')) try{ URL.revokeObjectURL(u); }catch(e){}
});
window._audioBlobCache={};
}
if(window._imageBlobCache){
Object.values(window._imageBlobCache).forEach(u=>{
if(u && u.startsWith('blob:')) try{ URL.revokeObjectURL(u); }catch(e){}
});
window._imageBlobCache={};
}
// Keep _aborted=true; only clear the flow/step/registries
SS.flow=[]; SS.step=0;
SS._allAudios=[]; SS._allOscs=[];
SS._activeAudio=null;
// Bump phase token so any stale callbacks checking it will see a mismatch
SS._phaseToken=(SS._phaseToken||0)+1000;
exitFullscreen();
navTo('pg-sections');
}
/* ── progress tracker ── */
function setTracker(activePart){
for(let i=1;i<=3;i++){
const node=document.getElementById('ptn-'+i);
const line=document.getElementById('ptl-'+i);
if(!node)continue;
node.className='pt-node '+(i0){
SS.flow.push({
type:'instruction', partNum:1,
audio: (DB.intros&&DB.intros.p11&&DB.intros.p11.audio)||'',
text: (DB.intros&&DB.intros.p11&&DB.intros.p11.text)||"In this part, I'm going to ask you some short questions about yourself and your interests. Begin speaking when you hear this sound.",
beepAfter:true
});
// Grab Q4 images so we can show them during Q5/Q6 too
const q4Data = p1Bucket[0] || {};
const q4Imgs = [q4Data.img, q4Data.img2].filter(Boolean);
let p12Inserted = false;
p1List.forEach(item=>{
const {q, qNum, isQ4} = item;
// Insert Part 1.2 intro just before the first Q in Part 1.2 (qNum>=4)
if(qNum>=4 && !p12Inserted){
SS.flow.push({
type:'instruction', partNum:1,
audio: (DB.intros&&DB.intros.p12&&DB.intros.p12.audio)||'',
text: (DB.intros&&DB.intros.p12&&DB.intros.p12.text)||"Now I'm going to show you some pictures and ask you questions about them.",
beepAfter:true,
timingOverride: '10 sec preparation · 45 sec speaking (first question)'
});
p12Inserted = true;
}
const partLabel = qNum<=3 ? 'PART 1.1' : 'PART 1.2';
// Q4 shows its own images; Q5/Q6 also show Q4's images
const showImgs = (qNum>=4) ? q4Imgs : [];
SS.flow.push({
type:'q', partNum:1, partLabel, q, qNum,
prepSec: isQ4?10:5,
maxSec: isQ4?45:30,
isQ4,
p12Imgs: showImgs
});
});
}
/* ── PART 2 ── */
const p2Q=(Q.part2||[]).filter(Boolean)[0];
if(p2Q){
SS.flow.push({
type:'instruction', partNum:2,
audio: (DB.intros&&DB.intros.p2&&DB.intros.p2.audio)||'',
text: (DB.intros&&DB.intros.p2&&DB.intros.p2.text)||"In this part, I'm going to show you a picture and ask you three questions.",
beepAfter:true
});
SS.flow.push({type:'p2prep', q:p2Q, prepSec:60, speakSec:120});
}
/* ── PART 3 ── */
const p3Qs=(Q.part3||[]).filter(Boolean);
if(p3Qs.length>0){
SS.flow.push({
type:'instruction', partNum:3,
audio: (DB.intros&&DB.intros.p3&&DB.intros.p3.audio)||'',
text: (DB.intros&&DB.intros.p3&&DB.intros.p3.text)||"In this part, you are going to speak on a topic for two minutes.",
beepAfter:true
});
p3Qs.forEach((q,i)=>{
SS.flow.push({type:'p3', partNum:3, q, maxSec:120, i, total:p3Qs.length, prepSec:60});
});
}
SS.flow.push({type:'done'});
SS.step=0;
setTracker(0);
nextStep();
}
// Updates the SKIP button label based on the current exam phase.
// Called by phase functions whenever the phase changes.
function setSkipLabel(label){
const btn = document.getElementById('examSkipBtn');
if(btn) btn.textContent = 'SKIP ' + (label || '').toUpperCase() + ' >';
}
function nextStep(){
if(SS._aborted) return;
if(SS.step>=SS.flow.length)return;
const step=SS.flow[SS.step];
if(step.partNum){
document.getElementById('speakPartLbl').textContent='PART '+step.partNum;
setTracker(step.partNum);
}
if(step.type==='countdown') showCountdown(3,()=>{SS.step++;nextStep();});
else if(step.type==='instruction') showInstruction(step);
else if(step.type==='q') showQuestion(step);
else if(step.type==='p2prep') showP2Prep(step);
else if(step.type==='p3') showP3(step);
else if(step.type==='done') showDone();
}
/* ── 3-2-1 opening countdown ── */
function showCountdown(n,cb){
if(SS._aborted) return;
const stage=document.getElementById('speakStage');
if(n<0){ if(!SS._aborted) cb(); return; }
stage.innerHTML=`
${n===0?'GO!':n}
${n===0?'SPEAK NOW':'GET READY'}
`;
SS.timer=setTimeout(()=>showCountdown(n-1,cb),900);
}
/* ── Part instruction screen ── */
function showInstruction(step){
const stage=document.getElementById('speakStage');
const labels={1:'PART 1',2:'PART 2',3:'PART 3'};
const partLabel=labels[step.partNum]||'PART '+step.partNum;
document.getElementById('speakPartLbl').textContent=partLabel;
// Timing info per part — can be overridden per step
const timing = step.timingOverride || ({
1:'Questions about yourself and your interests',
2:'1 min preparation · 2 min speaking',
3:'1 min preparation · 2 min speaking'
}[step.partNum]||'');
stage.innerHTML=`
${partLabel}
${escHtml(step.text||'')}
${timing}
`;
// Skip label varies by part. Use partNum + sub if step has subPart info
const subText = step.partNum===1
? (step.timingOverride && /first question/i.test(step.timingOverride) ? 'Part 1.2 Introduction' : 'Part 1.1 Introduction')
: ('Part ' + step.partNum + ' Introduction');
setSkipLabel(subText);
const myPhaseToken = ++SS._phaseToken || (SS._phaseToken=1);
const tokenIsCurrent = () => SS._phaseToken === myPhaseToken && !SS._aborted;
let afterSpeakDone=false;
const afterSpeak=()=>{
if(afterSpeakDone) return;
afterSpeakDone=true;
if(!tokenIsCurrent()) return;
if(step.beepAfter){
let beepDone=false;
beep(()=>{
if(beepDone) return; beepDone=true;
if(!tokenIsCurrent()) return;
SS.step++; nextStep();
});
} else {
SS.step++; nextStep();
}
};
const introAudio = step.audio&&step.audio.trim() ? step.audio : '';
if(introAudio){
// Stop any previously-playing audio (critical — prevents overlapping intros)
_stopCurrentAudio();
// Use blob URL from preload cache if available
const playSrc = (window._audioBlobCache && window._audioBlobCache[introAudio]) || introAudio;
const au=new Audio(playSrc);
au.preload='auto';
_registerExamAudio(au);
SS._activeAudio=au;
let done=false;
let durTimer=null;
let metadataLoaded=false;
let playbackStarted=false;
let errorCount=0;
const finish=()=>{
if(done) return;
done=true;
clearTimeout(audioWatch); clearTimeout(durTimer);
au.oncanplay=null; au.onended=null; au.onerror=null;
au.onloadedmetadata=null; au.onplaying=null; au.ontimeupdate=null;
try{ au.pause(); }catch(e){}
if(SS._activeAudio===au) SS._activeAudio=null;
if(tokenIsCurrent()) afterSpeak();
};
// Watchdog: if audio never starts loading at all within 20 seconds, give up.
// This watchdog is CANCELLED the moment we see ANY sign of life (metadata OR playback).
const audioWatch=setTimeout(()=>{
if(!metadataLoaded && !playbackStarted) finish();
}, 20000);
au.onloadedmetadata=()=>{
metadataLoaded=true;
clearTimeout(audioWatch);
if(done || !tokenIsCurrent()) return;
if(durTimer) return;
const dur=(isFinite(au.duration)&&au.duration>0)?au.duration*1000+1500:60000;
durTimer=setTimeout(finish, dur);
};
au.onplaying=()=>{
playbackStarted=true;
clearTimeout(audioWatch);
};
au.onended=finish;
// On error: don't skip immediately — try once more with original URL
// (blob cache might be stale; or fall through to 3.5s text-only intro)
au.onerror=()=>{
errorCount++;
if(errorCount === 1 && playSrc !== introAudio){
// Try original URL as fallback
au.src = introAudio;
au.load();
return;
}
// Both attempts failed — show text-only intro for 3.5s so user can read
console.warn('Intro audio failed, falling back to text only');
clearTimeout(audioWatch);
if(!done){
durTimer = setTimeout(finish, 3500);
}
};
au.play().catch(()=>{
// Autoplay block — fall through to text-only timer
if(!done && !durTimer){
durTimer = setTimeout(finish, 3500);
}
});
} else {
SS.timer=setTimeout(()=>{ if(tokenIsCurrent()) afterSpeak(); }, 3500);
}
}
/* ── Part 1 question: prep → beep → answer → beep ── */
function showQuestion(step){
const stage=document.getElementById('speakStage');
const q=step.q;
// Update header label to Part 1.1 or 1.2
document.getElementById('speakPartLbl').textContent=step.partLabel||'PART 1';
// Part 1.2 (Q4-Q6) all show the same images uploaded for Q4
let imgsHtml='';
const imgs = (step.p12Imgs && step.p12Imgs.length > 0) ? step.p12Imgs : [];
if(imgs.length>0){
imgsHtml=`
`;
showToast('Speaking exam complete!');
return;
}
// AI WAS enabled → trigger AI assessment flow
// First flush any buffered uploads
showToast('Speaking exam complete!');
exitFullscreen();
navTo('pg-analyzing');
// Stash exam metadata for the assess call
window._examMetadata = buildExamMetadata(examId);
// Start countdown + flush buffer + call AI
startAnalyzingCountdown();
ExamRecorder.flushBuffer().then(()=>{
callAssessAPI(examId);
}).catch(err=>{
console.warn('flush buffer error:', err);
callAssessAPI(examId);
});
}
/* ════════════════════════════════════════
IELTS EXAM FLOW
Parallel to the Multilevel flow above. Reuses ExamRecorder, speakQ,
startCountdown, beep, abortSpeaking. Differences from Multilevel:
- Dynamic question count (admin-defined list per part)
- Audio-only Parts 1 & 3 (no on-screen text during speaking)
- Student-controlled STOP button, activated after 5s into each speaking phase
- Hard time caps: 30s P1, 2 min P2, 60s P3 — on hit, play the warning audio
and auto-advance.
- Part 2 has the cue card visible during prep AND speaking.
════════════════════════════════════════ */
// IELTS time caps (seconds). Centralised so they're easy to tweak.
const IELTS_CAPS = { p1: 30, p2: 120, p3: 60 };
const IELTS_STOP_DELAY_SEC = 5; // STOP button enables this many seconds in
const IELTS_P2_PREP_SEC = 60; // Part 2: 1 minute prep before speaking
function beginIeltsExamFlow(){
SS = { _aborted:false, _allAudios:[], _allOscs:[], _phaseToken:0 };
const mock = window._activeMock;
if(!mock){ abortSpeaking(); return; }
trackExamStart(mock.id);
const skipBtn = document.getElementById('examSkipBtn');
if(skipBtn) skipBtn.style.display = ''; // We hide it inside speaking phases since IELTS uses a STOP button.
const Q = mock.questions || {};
SS.flow = [];
SS._aiEnabled = !!window._aiEnabledForExam;
if(SS._aiEnabled && window._micStream){
try{ ExamRecorder.init(window._micStream); }
catch(e){ console.warn('IELTS recorder init failed:', e); SS._aiEnabled = false; }
} else {
SS._aiEnabled = false;
}
// No opening 3-2-1 countdown for IELTS — exam begins directly with Part 1 intro.
// Assign qNum per question — same numbering scheme as buildExamMetadata.
let qNum = 1;
const p1List = (Q.part1 || []).filter(Boolean);
const p2Card = (Q.part2 || []).filter(Boolean)[0] || null;
const p3List = (Q.part3 || []).filter(Boolean);
// ── PART 1 intro + questions ──
if(p1List.length > 0){
SS.flow.push({
type: 'instruction', partNum: 1,
audio: (DB.intros && DB.intros.ielts_p1 && DB.intros.ielts_p1.audio) || '',
text: (DB.intros && DB.intros.ielts_p1 && DB.intros.ielts_p1.text) || "In this part, I'll ask you some questions about yourself.",
beepAfter: true
});
p1List.forEach((q, i) => {
SS.flow.push({
type: 'ielts_qa', partNum: 1, q, qNum,
maxSec: IELTS_CAPS.p1, qIndex: i+1, qTotal: p1List.length
});
qNum++;
});
}
// ── PART 2 intro + cue card ──
if(p2Card){
SS.flow.push({
type: 'instruction', partNum: 2,
audio: (DB.intros && DB.intros.ielts_p2 && DB.intros.ielts_p2.audio) || '',
text: (DB.intros && DB.intros.ielts_p2 && DB.intros.ielts_p2.text) || "In this part, I'll give you a topic card. You'll have one minute to prepare.",
beepAfter: true
});
SS.flow.push({ type: 'ielts_p2', card: p2Card, qNum, prepSec: IELTS_P2_PREP_SEC, maxSec: IELTS_CAPS.p2 });
qNum++;
}
// ── PART 3 intro + questions ──
if(p3List.length > 0){
SS.flow.push({
type: 'instruction', partNum: 3,
audio: (DB.intros && DB.intros.ielts_p3 && DB.intros.ielts_p3.audio) || '',
text: (DB.intros && DB.intros.ielts_p3 && DB.intros.ielts_p3.text) || "Let's discuss some more abstract questions.",
beepAfter: true
});
p3List.forEach((q, i) => {
SS.flow.push({
type: 'ielts_qa', partNum: 3, q, qNum,
maxSec: IELTS_CAPS.p3, qIndex: i+1, qTotal: p3List.length
});
qNum++;
});
}
SS.flow.push({ type: 'done' });
SS.step = 0;
setTracker(0);
ieltsNextStep();
}
// IELTS step dispatcher — replaces nextStep for IELTS flows.
function ieltsNextStep(){
if(SS._aborted) return;
if(SS.step >= SS.flow.length) return;
const step = SS.flow[SS.step];
if(step.partNum){
document.getElementById('speakPartLbl').textContent = 'PART '+step.partNum;
setTracker(step.partNum);
}
if(step.type === 'countdown') showCountdown(3, () => { SS.step++; ieltsNextStep(); });
else if(step.type === 'instruction') showIeltsInstruction(step);
else if(step.type === 'ielts_qa') showIeltsP1or3Question(step);
else if(step.type === 'ielts_p2') showIeltsP2(step);
else if(step.type === 'done') showDone(); // reuses the Multilevel "done" → AI assessment path
}
// IELTS part-intro screen. Same shape as Multilevel's showInstruction but the
// flow advance calls ieltsNextStep so we don't fall back into Multilevel logic.
function showIeltsInstruction(step){
const stage = document.getElementById('speakStage');
const labels = { 1:'PART 1', 2:'PART 2', 3:'PART 3' };
const partLabel = labels[step.partNum] || ('PART '+step.partNum);
document.getElementById('speakPartLbl').textContent = partLabel;
const timingMap = {
1:'Interview · ~30s per question',
2:'Cue card · 1 min prep · up to 2 min speaking',
3:'Discussion · ~1 min per question'
};
const timing = timingMap[step.partNum] || '';
stage.innerHTML = `
${partLabel}
${escHtml(step.text||'')}
${timing}
`;
setSkipLabel('Part '+step.partNum+' Introduction');
const myToken = ++SS._phaseToken || (SS._phaseToken=1);
const tokenIsCurrent = () => SS._phaseToken === myToken && !SS._aborted;
let advanced = false;
const advance = () => {
if(advanced) return; advanced = true;
if(!tokenIsCurrent()) return;
if(step.beepAfter){
let beepDone = false;
beep(() => { if(beepDone) return; beepDone = true; if(!tokenIsCurrent()) return; SS.step++; ieltsNextStep(); });
} else {
SS.step++; ieltsNextStep();
}
};
const introAudio = step.audio && step.audio.trim() ? step.audio : '';
if(introAudio){
_stopCurrentAudio();
const playSrc = (window._audioBlobCache && window._audioBlobCache[introAudio]) || introAudio;
const au = new Audio(playSrc);
au.preload = 'auto';
_registerExamAudio(au);
SS._activeAudio = au;
let done = false, durTimer = null, metaLoaded = false, played = false;
const watch = setTimeout(() => { if(!metaLoaded && !played){ done = true; advance(); } }, 20000);
const finish = () => {
if(done) return; done = true;
clearTimeout(watch); clearTimeout(durTimer);
au.oncanplay = au.onended = au.onerror = au.onloadedmetadata = au.onplaying = null;
try{ au.pause(); }catch(e){}
if(SS._activeAudio === au) SS._activeAudio = null;
if(tokenIsCurrent()) advance();
};
au.onloadedmetadata = () => {
metaLoaded = true;
clearTimeout(watch);
if(done || !tokenIsCurrent()) return;
const dur = (isFinite(au.duration) && au.duration > 0) ? au.duration*1000+1500 : 60000;
durTimer = setTimeout(finish, dur);
};
au.onplaying = () => { played = true; clearTimeout(watch); };
au.onended = finish;
au.onerror = () => { clearTimeout(watch); if(!done) durTimer = setTimeout(finish, 3500); };
au.play().catch(() => { if(!done && !durTimer) durTimer = setTimeout(finish, 3500); });
} else {
SS.timer = setTimeout(() => { if(tokenIsCurrent()) advance(); }, 3500);
}
}
// IELTS Part 1 or Part 3 question. Audio-only to the student during the speaking
// phase (no text). Recording starts right after the question audio finishes;
// the STOP button activates after IELTS_STOP_DELAY_SEC; the hard cap fires the
// warning audio and auto-advances. Internal STOP and hard-cap converge to one
// "finish" path so we never end the question twice.
function showIeltsP1or3Question(step){
const stage = document.getElementById('speakStage');
const partLabel = (step.partNum === 1) ? 'PART 1' : 'PART 3';
document.getElementById('speakPartLbl').textContent = partLabel;
const myToken = ++SS._phaseToken || (SS._phaseToken=1);
const tokenIsCurrent = () => SS._phaseToken === myToken && !SS._aborted;
// Reading phase: an audio-reactive waveform while the question audio plays.
// No text shown to the student (audio-only, like real IELTS).
const renderReading = () => {
stage.innerHTML = `
${partLabel} — Question ${step.qIndex} of ${step.qTotal}
${ieltsWaveMarkup()}
${Array(8).fill('').join('')}
QUESTION
`;
};
// Speaking phase: audio-only — just the timer + waveform + STOP button.
// The STOP button is rendered immediately but disabled (greyed) for the first
// IELTS_STOP_DELAY_SEC, then activates. This protects accidental tap-to-zero.
const renderSpeaking = (timerVal) => {
stage.innerHTML = `
${partLabel} — Question ${step.qIndex} of ${step.qTotal}
${Array(10).fill('').join('')}
SPEAK NOW
RECORDING
`;
};
// ── Phase 1: play question audio ──
renderReading();
setSkipLabel('Q'+step.qNum+' Reading');
// Hide the SKIP button during IELTS speaking (we use STOP instead).
const skipBtn = document.getElementById('examSkipBtn');
// Kick the audio-reactive waveform shortly after playback starts. speakQ sets
// SS._activeAudio synchronously and calls play(); a small delay lets the
// element begin so the analyser has a live source.
setTimeout(() => { if(tokenIsCurrent()) startIeltsWave(); }, 350);
speakQ(step.q, () => {
stopIeltsWave();
if(!tokenIsCurrent()) return;
// ── Phase 2: speaking ──
renderSpeaking(step.maxSec);
if(skipBtn) skipBtn.style.display = 'none';
setSkipLabel('Q'+step.qNum);
// Start recording immediately. 30s/60s clock starts NOW per your spec.
if(SS._aiEnabled){
try{ ExamRecorder.startQ(step.qNum); }catch(e){ console.warn('IELTS startQ:', e); }
}
// STOP button stays visibly disabled for IELTS_STOP_DELAY_SEC, then activates.
// No ticking countdown text (it competed with the real speaking timer).
let stopActivated = false;
const stopActivateT = setTimeout(() => {
if(SS._aborted || !tokenIsCurrent()) return;
stopActivated = true;
const b = document.getElementById('ieltsStopBtn');
if(b){
b.disabled = false;
b.style.background = 'var(--green)';
b.style.color = '#fff';
b.style.borderColor = 'var(--green)';
b.style.cursor = 'pointer';
b.style.opacity = '1';
b.style.boxShadow = '0 0 18px var(--greenGlow)';
b.textContent = 'I FINISHED SPEAKING';
}
}, IELTS_STOP_DELAY_SEC * 1000);
let answered = false;
// finishAnswer is the single termination path. cause: 'stop' (student tap)
// or 'cap' (hard time limit hit). On 'cap' we play the warning audio first;
// on 'stop' we just beep and move on.
const finishAnswer = (cause) => {
if(answered) return; answered = true;
clearTimeout(stopActivateT);
// Stop recording, upload in background.
if(SS._aiEnabled){
try{ ExamRecorder.stopAndUpload(step.qNum); }catch(e){ console.warn('IELTS stop:', e); }
}
// Disable any further STOP clicks
const b = document.getElementById('ieltsStopBtn');
if(b){ b.disabled = true; b.style.opacity = '0.4'; }
// Choose the right audio cue.
const playWarning = (cb) => {
const warn = (DB.intros && DB.intros.ielts_warn && DB.intros.ielts_warn.audio) || '';
if(!warn){ cb(); return; }
const src = (window._audioBlobCache && window._audioBlobCache[warn]) || warn;
const au = new Audio(src);
_registerExamAudio(au);
SS._activeAudio = au;
let done = false;
const safety = setTimeout(() => { if(!done){ done = true; cb(); } }, 8000);
au.onended = au.onerror = () => { if(done) return; done = true; clearTimeout(safety); cb(); };
au.play().catch(() => { if(done) return; done = true; clearTimeout(safety); cb(); });
};
const doneCb = () => {
if(!tokenIsCurrent()) return;
// Restore skip button visibility for the next phase (instructions).
if(skipBtn) skipBtn.style.display = '';
SS.step++;
ieltsNextStep();
};
if(cause === 'cap') playWarning(doneCb);
else beep(doneCb);
};
// Expose the finish hook for the STOP button onclick handler.
window._ieltsFinishAnswer = (cause) => {
if(cause === 'stop' && !stopActivated) return; // ignore stop taps before 5s
finishAnswer(cause);
};
// Hard cap timer — uses the same startCountdown that the rest of the app uses,
// so wall-clock-correct (immune to tab throttling). When it fires, we treat
// it as the 'cap' cause: warning audio + auto-advance.
startCountdown(step.maxSec, () => {
if(answered) return;
if(!tokenIsCurrent()) return;
finishAnswer('cap');
});
});
}
// Student-pressed STOP — relays into the current question's finish path.
function ieltsStopAnswer(){
if(typeof window._ieltsFinishAnswer === 'function'){
window._ieltsFinishAnswer('stop');
}
}
// IELTS Part 2 — the cue card is visible to the student during prep AND speaking.
// 1 min prep, then up to 2 min speaking with STOP-after-5s and the warning audio
// on hard cap.
function showIeltsP2(step){
const stage = document.getElementById('speakStage');
document.getElementById('speakPartLbl').textContent = 'PART 2';
const card = step.card || {};
const bullets = (card.bullets || []).filter(Boolean);
const cueCardHtml = `
`;
} else if(phase === 'speaking'){
// No visible countdown during speaking (matches the real exam). Just a
// recording indicator. The hard cap still runs invisibly in the background.
timerHtml = `
RECORDING
`;
} else {
// Preparation phase keeps its countdown — it's a genuine prep aid and the
// real exam gives a clear one-minute prep signal.
timerHtml = `
`;
};
// Reading + prep + speak chain
renderPhase('reading', step.prepSec, 'PREPARATION');
setSkipLabel('Part 2 Reading');
speakQ(card.audio ? { audio: card.audio } : { audio: '' }, () => {
if(!tokenIsCurrent()) return;
// Prep phase
renderPhase('prep', step.prepSec, 'PREPARATION TIME');
setSkipLabel('Part 2 Preparation');
startCountdown(step.prepSec, () => {
if(!tokenIsCurrent()) return;
// Speaking phase
beep(() => {
if(!tokenIsCurrent()) return;
renderPhase('speaking', step.maxSec, '');
setSkipLabel('Part 2 Speaking');
if(skipBtn) skipBtn.style.display = 'none';
if(SS._aiEnabled){
try{ ExamRecorder.startQ(step.qNum); }catch(e){ console.warn('IELTS P2 startQ:', e); }
}
// STOP stays disabled for IELTS_STOP_DELAY_SEC then activates — no countdown text.
let stopActivated = false;
const stopActivateT = setTimeout(() => {
if(SS._aborted || !tokenIsCurrent()) return;
stopActivated = true;
const b = document.getElementById('ieltsStopBtn');
if(b){
b.disabled = false;
b.style.background = 'var(--green)';
b.style.color = '#fff';
b.style.borderColor = 'var(--green)';
b.style.cursor = 'pointer';
b.style.opacity = '1';
b.style.boxShadow = '0 0 18px var(--greenGlow)';
b.textContent = 'I FINISHED SPEAKING';
}
}, IELTS_STOP_DELAY_SEC * 1000);
let answered = false;
const finishAnswer = (cause) => {
if(answered) return; answered = true;
clearTimeout(stopActivateT);
if(SS._aiEnabled){
try{ ExamRecorder.stopAndUpload(step.qNum); }catch(e){ console.warn('IELTS P2 stop:', e); }
}
const b = document.getElementById('ieltsStopBtn');
if(b){ b.disabled = true; b.style.opacity = '0.4'; }
const playWarning = (cb) => {
const warn = (DB.intros && DB.intros.ielts_warn && DB.intros.ielts_warn.audio) || '';
if(!warn){ cb(); return; }
const src = (window._audioBlobCache && window._audioBlobCache[warn]) || warn;
const au = new Audio(src);
_registerExamAudio(au);
SS._activeAudio = au;
let d=false; const safety = setTimeout(()=>{ if(!d){ d=true; cb(); }}, 8000);
au.onended = au.onerror = () => { if(d)return; d=true; clearTimeout(safety); cb(); };
au.play().catch(()=>{ if(d)return; d=true; clearTimeout(safety); cb(); });
};
const doneCb = () => {
if(!tokenIsCurrent()) return;
if(skipBtn) skipBtn.style.display = '';
SS.step++;
ieltsNextStep();
};
if(cause === 'cap') playWarning(doneCb);
else beep(doneCb);
};
window._ieltsFinishAnswer = (cause) => {
if(cause === 'stop' && !stopActivated) return;
finishAnswer(cause);
};
startCountdown(step.maxSec, () => {
if(answered) return;
if(!tokenIsCurrent()) return;
finishAnswer('cap');
});
});
});
});
}
// Build the exam metadata object that gets sent to the assess function
function buildExamMetadata(examId){
const mock = window._activeMock || {};
const Q = mock.questions || {};
const supaUrl = SUPA_URL+'/storage/v1/object/public/'+SUPA_BUCKET;
// CRITICAL: extension must match what ExamRecorder.uploadRecording() actually
// saved to Supabase. iOS Safari uses mp4/m4a; everyone else uses webm.
// Previously this was hard-coded to 'webm' which broke iOS entirely (Deepgram
// tried to fetch .webm URLs that didn't exist → all_transcriptions_failed).
const ext = (typeof ExamRecorder !== 'undefined' && ExamRecorder.getRecordingExt)
? ExamRecorder.getRecordingExt()
: 'webm';
const recordingUrl = qNum => supaUrl+'/recordings/'+examId+'/q'+qNum+'.'+ext;
// IELTS uses dynamic question lists. qNum is assigned at exam start so that the
// saved Supabase file `q{N}.webm` matches the qNum sent here. Numbering scheme:
// Part 1 questions: qNum = 1..N_p1
// Part 2 cue card: qNum = N_p1 + 1
// Part 3 questions: qNum = N_p1 + 2 .. N_p1 + 1 + N_p3
// ExamRecorder.startQ(qNum) is called with these same qNums during the flow.
if(mock.type === 'ielts'){
const p1List = (Q.part1 || []).filter(Boolean);
const p2Card = (Q.part2 || []).filter(Boolean)[0] || null;
const p3List = (Q.part3 || []).filter(Boolean);
const questions = [];
let qNum = 1;
// Part 1 — each question is a one-off interview question. No images.
p1List.forEach(q => {
questions.push({
qNum,
partLabel: 'Part 1',
examType: 'ielts',
text: q.text || '',
recordingUrl: recordingUrl(qNum),
hasRecording: true
});
qNum++;
});
// Part 2 — cue card. We send the topic, bullets, and image (if any).
if(p2Card){
questions.push({
qNum,
partLabel: 'Part 2',
examType: 'ielts',
topic: p2Card.topic || '',
bullets: Array.isArray(p2Card.bullets) ? p2Card.bullets.slice(0, 4) : [],
imgUrl: p2Card.img || '',
recordingUrl: recordingUrl(qNum),
hasRecording: true
});
qNum++;
}
// Part 3 — discussion questions.
p3List.forEach(q => {
questions.push({
qNum,
partLabel: 'Part 3',
examType: 'ielts',
text: q.text || '',
recordingUrl: recordingUrl(qNum),
hasRecording: true
});
qNum++;
});
return {
examId,
examType: 'ielts',
mockId: mock.id || '',
mockName: mock.name || '',
telegramHandle: window._examTelegramHandle || '',
questions
};
}
// Multilevel — unchanged. qNum mapping is by SLOT INDEX, not by filtered position:
// Q1=intro[0], Q2=intro[1], Q3=intro[2], Q4=part1[0], Q5=part1[1], Q6=part1[2],
// Q7=part2[0], Q8=part3[0]
// We must iterate by index — NOT filter(Boolean) — or a cleared middle slot
// would shift later questions left, so Q3's recording gets labeled as Q2 and
// the AI grades the wrong answer.
const intro = Q.intro || [];
const part1 = Q.part1 || [];
const part2 = Q.part2 || [];
const part3 = Q.part3 || [];
const questions = [];
// Q1-Q3 (Part 1.1) — iterate by slot index
for(let i=0;i<3;i++){
const q = intro[i];
if(!q) continue;
questions.push({
qNum: i+1, partLabel: 'Part 1.1',
text: q.text||'', recordingUrl: recordingUrl(i+1), hasRecording: true
});
}
// Q4-Q6 (Part 1.2) — iterate by slot index; images attach only to Q4
// (sending them again on Q5/Q6 would just duplicate the AI's vision input).
// Per official format: image 1 is on q.img, image 2 is on q.img2.
for(let i=0;i<3;i++){
const q = part1[i];
if(!q) continue;
const isFirst = (i === 0);
// Use the part1[0] images for Q4 specifically — never inherit from a
// different slot index, since iterating by index already preserves position.
const imgUrl = isFirst ? (q.img || '') : '';
const img2Url = isFirst ? (q.img2 || '') : '';
questions.push({
qNum: i+4, partLabel: 'Part 1.2',
text: q.text||'',
imgUrl,
img2Url,
recordingUrl: recordingUrl(i+4), hasRecording: true
});
}
// Q7 (Part 2) — slot 0 only
if(part2[0]){
const q = part2[0];
questions.push({
qNum: 7, partLabel: 'Part 2',
text: q.text||'',
cueCard: q.cueCard||'',
imgUrl: q.img||'',
recordingUrl: recordingUrl(7), hasRecording: true
});
}
// Q8 (Part 3) — slot 0 only
if(part3[0]){
const q = part3[0];
// Parse cue card: line 0 = topic, FOR:..., AGAINST:...
let topic = q.text||'';
let forItems=[], againstItems=[];
if(q.cueCard){
const lines = q.cueCard.split('\n');
topic = lines[0]||topic;
lines.forEach(l=>{
const lu=l.toUpperCase();
if(lu.startsWith('FOR:')) forItems = l.slice(4).split('|').map(x=>x.trim()).filter(Boolean);
else if(lu.startsWith('AGAINST:')) againstItems = l.slice(8).split('|').map(x=>x.trim()).filter(Boolean);
});
}
questions.push({
qNum: 8, partLabel: 'Part 3',
topic: topic,
forItems, againstItems,
recordingUrl: recordingUrl(8), hasRecording: true
});
}
return {
examId,
mockId: mock.id||'',
mockName: mock.name||'',
telegramHandle: window._examTelegramHandle||'',
questions
};
}
let _analyzingTimer = null;
let _analyzingDeadline = 0;
let _analyzingFallbackFired = false;
function startAnalyzingCountdown(){
const TOTAL_MS = 70 * 1000; // 30s attempt + 5s wait + 30s retry + small buffer
const CIRCUMFERENCE = 339.292; // 2 * PI * 54
_analyzingDeadline = Date.now() + TOTAL_MS;
_analyzingFallbackFired = false;
// Stages — student-friendly. Cycle every 8-10 seconds.
const stages = [
'Listening to your speech',
'Checking your grammar',
'Evaluating your vocabulary',
'Assessing your fluency',
'Analyzing your pronunciation',
'Comparing with exam rubric',
'Compiling your feedback'
];
if(_analyzingTimer) clearInterval(_analyzingTimer);
const tick = () => {
const tEl = document.getElementById('analyzingTimer');
const mEl = document.getElementById('analyzingMsg');
const circle = document.getElementById('anlzProgressCircle');
const remaining = Math.max(0, Math.ceil((_analyzingDeadline - Date.now())/1000));
const elapsed = Math.max(0, Math.floor((TOTAL_MS - (_analyzingDeadline - Date.now())) / 1000));
// When the count hits 0, show ellipsis instead of "0" — the showAssessBusy
// transition happens in ~250ms so this avoids a confusing single-digit flash.
if(tEl) tEl.textContent = remaining > 0 ? String(remaining) : '…';
// SVG circle drains as time elapses (offset increases from 0 to CIRCUMFERENCE)
if(circle){
const pct = Math.min(1, elapsed / 60);
const offset = CIRCUMFERENCE * pct;
circle.style.strokeDashoffset = offset;
}
// Stage messages cycle every ~8 seconds
if(mEl){
const idx = Math.min(stages.length-1, Math.floor(elapsed / 8));
const newMsg = stages[idx];
if(mEl.textContent !== newMsg) mEl.textContent = newMsg;
}
if(remaining <= 0 && !_analyzingFallbackFired){
_analyzingFallbackFired = true;
clearInterval(_analyzingTimer);
_analyzingTimer = null;
try{ trackAIBusy(); }catch(e){}
showAssessBusy('timeout');
}
};
// Reset the visual circle
const circle = document.getElementById('anlzProgressCircle');
if(circle) circle.style.strokeDashoffset = '0';
tick();
_analyzingTimer = setInterval(tick, 250);
}
function stopAnalyzingCountdown(){
if(_analyzingTimer){ clearInterval(_analyzingTimer); _analyzingTimer = null; }
_analyzingFallbackFired = true;
}
async function callAssessAPI(examId){
const meta = window._examMetadata;
if(!meta){ showAssessBusy('no_metadata'); return; }
trackAIAttempt();
// We retry once on transient errors (rate limits, parse fails) with a brief wait.
// No background queue, no Telegram. If both attempts fail, show "AI is busy" page.
const MAX_ATTEMPTS = 2;
const attemptOnce = async () => {
const ctrl = new AbortController();
const tmr = setTimeout(()=>ctrl.abort(), 30000);
try {
const res = await fetch('/.netlify/functions/assess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
signal: ctrl.signal
});
clearTimeout(tmr);
return await res.json();
} finally {
clearTimeout(tmr);
}
};
for(let attempt=1; attempt<=MAX_ATTEMPTS; attempt++){
try {
const data = await attemptOnce();
// Success
if(data.ok && data.feedback){
stopAnalyzingCountdown();
trackAISuccess();
window._lastFeedback = data.feedback;
window._lastFeedbackExamId = examId;
window._lastFeedbackMockName = meta.mockName;
renderFeedback(data.feedback);
return;
}
// Fatal → diagnostic page immediately, no retry
if(data.fatal){
stopAnalyzingCountdown();
trackAIFailed();
showAssessError(data.reason||'unknown', data.debug||'');
return;
}
// Parse failure: retry won't help, AI gave bad JSON twice in a row is unlikely
// to magically fix. Show diagnostic so user sees what happened. Saves AI cost.
if(data.reason === 'ai_parse_failed'){
stopAnalyzingCountdown();
trackAIFailed();
showAssessError('ai_parse_failed', data.debug||'');
return;
}
// Transient (queued:true) — retry helps for rate limits / network. Show "busy" on last attempt.
if(attempt < MAX_ATTEMPTS){
const mEl = document.getElementById('analyzingMsg');
if(mEl) mEl.textContent = 'AI BUSY — RETRYING...';
await new Promise(r => setTimeout(r, 5000));
continue;
}
stopAnalyzingCountdown();
trackAIBusy();
showAssessBusy(data.reason || 'busy');
return;
} catch(err) {
if(attempt < MAX_ATTEMPTS){
const mEl = document.getElementById('analyzingMsg');
if(mEl) mEl.textContent = 'CONNECTION HICCUP — RETRYING...';
await new Promise(r => setTimeout(r, 3000));
continue;
}
stopAnalyzingCountdown();
console.warn('assess call failed after retries:', err);
trackAIBusy();
showAssessBusy('network');
return;
}
}
}
// Show "AI is temporarily busy, try again later" page. Used when assessment
// times out or rate-limits. No queue/no telegram — student can retry the
// exam later when capacity is available.
function showAssessBusy(reason){
navTo('pg-feedback');
// Release mic — exam flow ended
try{ if(typeof ExamRecorder!=='undefined') ExamRecorder.abort(); }catch(e){}
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
const el = document.getElementById('feedbackContent');
if(!el) return;
// Friendly description of the underlying reason — helps both students and admin
const reasonMap = {
'timeout': 'The assessment took longer than expected',
'ai_rate_limited': 'AI service is currently rate-limited',
'transcription_slow':'Audio transcription is taking longer than usual',
'network': 'Network connection issue',
'unexpected_error': 'An unexpected server issue occurred',
'no_metadata': 'Exam data was missing',
'busy': 'AI service is currently very busy'
};
const reasonText = reasonMap[reason] || ('Reason: ' + reason);
el.innerHTML = `
${mIcon('hourglass', 56)}
AI IS TEMPORARILY BUSY
${escHtml(reasonText)}.
Please try the same mock again in a few minutes. Your data wasn't lost.
Technical details
Code: ${escHtml(reason)}
`;
}
// Show a clear error page when AI couldn't be assessed for a configuration reason.
// Used for fatal errors (out of credit, auth fail, etc.) that retrying won't fix.
function showAssessError(reason, debug){
// Detect "out of credit" from the debug string — give a friendlier message
const isOutOfCredit = /402|requires more credits|insufficient credit/i.test(debug || '');
let reasonText = {
'missing_env_vars': 'Server is missing API keys',
'all_transcriptions_failed': 'Audio transcription service rejected all recordings',
'openrouter_failed': 'AI assessment service is unreachable',
'ai_parse_failed': 'AI returned an invalid response',
'unexpected_error': 'Unexpected server error',
'network': 'Network problem — could not reach our server',
'no_metadata': 'Exam data missing'
}[reason] || ('Error: ' + reason);
// Detect specific error patterns from the debug string
if(isOutOfCredit){
reasonText = 'AI service credit has been used up. The admin needs to top up the account before assessments can resume.';
} else if(/Unsupported image format|\.avif|\.webp/i.test(debug||'')){
reasonText = 'The mock has an unsupported image format (likely AVIF or WebP). Admin needs to re-upload the image — the latest version of this platform auto-converts to JPEG.';
}
navTo('pg-feedback');
// Release mic — exam flow ended (errored out)
try{ if(typeof ExamRecorder!=='undefined') ExamRecorder.abort(); }catch(e){}
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
const el = document.getElementById('feedbackContent');
if(!el) return;
el.innerHTML = `
${mIcon(isOutOfCredit?'credit':'warn', 48)}
${isOutOfCredit?'AI SERVICE PAUSED':'FEEDBACK UNAVAILABLE'}
${escHtml(reasonText)}
${debug ? `Technical details
${escHtml(debug)}
` : ''}
Your recordings have been kept on the server. Contact admin if this persists.
`;
}
// Student-initiated exit from the DONE screen — leaves fullscreen and goes to sections
function exitDoneScreen(){
SS._aborted=true;
// Stop recorder and release mic
try{ if(typeof ExamRecorder!=='undefined') ExamRecorder.abort(); }catch(e){}
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
// Free preload cache
if(window._audioBlobCache){
Object.values(window._audioBlobCache).forEach(u=>{
if(u && u.startsWith('blob:')) try{ URL.revokeObjectURL(u); }catch(e){}
});
window._audioBlobCache={};
}
if(window._imageBlobCache){
Object.values(window._imageBlobCache).forEach(u=>{
if(u && u.startsWith('blob:')) try{ URL.revokeObjectURL(u); }catch(e){}
});
window._imageBlobCache={};
}
exitFullscreen();
navTo('pg-sections');
}
/* ════════════════════════════════════════
FEEDBACK RENDERING (after AI assessment success)
════════════════════════════════════════ */
function renderFeedback(fb){
const el = document.getElementById('feedbackContent');
if(!el) return;
navTo('pg-feedback');
// Release mic now since exam is fully complete
try{ if(typeof ExamRecorder!=='undefined') ExamRecorder.abort(); }catch(e){}
if(window._micStream){
try{ window._micStream.getTracks().forEach(t=>t.stop()); }catch(e){}
window._micStream = null;
}
// IELTS feedback has a numeric band_score on overall — route to its renderer.
const overallProbe = fb.overall || {};
if(typeof overallProbe.band_score === 'number' || (window._activeMock && window._activeMock.type === 'ielts')){
return renderIeltsFeedback(fb, el);
}
const overall = fb.overall || {};
const ss = overall.subskills || {};
const certEligible = !!fb.certificate_eligible;
const cefr = overall.cefr_level || '-';
// Use server-provided rating score if available (matches official Multilevel
// rating scale). Fall back to client-side lookup if older response.
let totalScore, rawScore = 0, rawMax = 0;
(fb.parts||[]).forEach(p => {
if (!p || !p.rubric_a_max || p.rubric_a_max <= 0) return;
rawScore += (p.rubric_a_score||0);
rawMax += p.rubric_a_max;
});
if (typeof overall._ratingScore === 'number') {
totalScore = overall._ratingScore;
} else {
totalScore = rawToRating(rawScore);
}
const totalMax = 75;
let html = `
`;
// General advice (top-level guidance)
if(overall.general_advice && overall.general_advice.length){
html += `
${mIcon('advice', 22)}GENERAL ADVICE
${overall.general_advice.map(a=>`
${escHtml(a)}
`).join('')}
`;
}
// Per-part sections
html += `
DETAILED FEEDBACK BY PART
`;
(fb.parts||[]).forEach((p, idx)=>{
html += renderPart(p, idx);
});
// Certificate / encouragement
if(certEligible){
html += `
${mIcon('cert', 56)}
CERTIFICATE UNLOCKED
You qualified for an official Matrix Mock certificate at level ${escHtml(cefr)}.
`;
} else if(fb.encouragement_message){
html += `
${mIcon('encourage', 28)}
${escHtml(fb.encouragement_message)}
Certificates are awarded for B2 or C1 results.
`;
}
// Action buttons
html += `
`;
el.innerHTML = html;
// Smooth-scroll to top of feedback after render
setTimeout(()=>{
const wrap = document.getElementById('pg-feedback');
if(wrap) wrap.scrollTo({top:0, behavior:'smooth'});
}, 100);
}
function renderSubSkillCard(name, level, icon){
return `
${icon}
${escHtml(name)}
${escHtml(level||'-')}
`;
}
function renderPart(p, idx){
const exceeded = !!p.exceeded_ceiling;
const score = p.rubric_a_score || 0;
const max = p.rubric_a_max || 5;
const pct = max > 0 ? Math.round((score/max)*100) : 0;
// Pre-collapsed for parts 2-4 (so initial view isn't overwhelming)
const isOpen = idx === 0;
const partId = 'fbpart-'+idx;
let html = `
${exceeded ? `
★ You exceeded the expected ceiling. Your level is from the CEFR scale (above the per-part rubric).
` : ''}`;
(p.questions||[]).forEach(q=>{ html += renderQuestion(q); });
html += `
`;
return html;
}
function renderQuestion(q){
if(q.no_recording){
return `
Question ${q.q_num}
${mIcon('warn', 18)}This answer was not recorded due to a network interruption. Stay on Wi-Fi during exams to avoid this.
`;
}
let html = `
Question ${q.q_num}
`;
if(q.transcript){
html += `
YOUR ANSWER
"${escHtml(q.transcript)}"
`;
}
if(q.verbatim_copy){
html += `
${mIcon('warn', 18)}You read directly from the on-screen arguments. In Part 3, paraphrase and add your own reasoning, not just read the FOR/AGAINST list.
`;
(p.questions||[]).forEach(q=>{ html += renderIeltsQuestion(q); });
html += `
`;
return html;
}
function renderIeltsQuestion(q){
if(q.no_recording){
return `
Question ${q.q_num}
${mIcon('warn', 18)}This answer was not recorded due to a network interruption. Stay on Wi-Fi during exams to avoid this.
`;
}
const b = q.bands || {};
const fmtB = x => (typeof x === 'number') ? (Number.isInteger(x) ? x+'.0' : ''+x) : '-';
let html = `
Question ${q.q_num}
`;
// Per-question band chips removed by design — bands are shown per PART only.
// We still flag intro/identity questions as not scored.
if(q.unscored){
html += `
Introductory question — not scored in IELTS.
`;
}
if(q.transcript){
html += `
YOUR ANSWER
"${escHtml(q.transcript)}"
`;
}
if(q.grammar_issues && q.grammar_issues.length){
html += `
${mIcon('grammar', 18)} Grammar
`;
q.grammar_issues.forEach(g=>{
// Some grammar items are general notes with no original/correction — render note-only
if((!g.original || !g.original.trim()) && g.note){
html += `
Folders group mocks by period (e.g. "January 2026"). Students pick a track, then see these folders, then the mocks inside. A mock can be in several folders. Mocks in no folder appear in an automatic "Ungrouped" folder for students.
`,true);
edQTab(1);
}
/* ════════════════════════════════════════
IELTS SPEAKING EDITOR
Three tabs: Part 1 (dynamic question list), Part 2 (cue card),
Part 3 (dynamic question list). Unlike Multilevel's fixed 8-slot
editor, IELTS lists are admin-sized — you add or remove questions.
Each question has audio (required) + text (admin reference, not
shown to student during the exam).
════════════════════════════════════════ */
let _ieltsEditPart = 1; // which part is currently visible (1/2/3)
let _ieltsEditQId = null; // when editing a specific Part 1/3 question, its id
function openIeltsSpeakingEditor(){
try {
closeModal();
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ showToast('Mock not found'); return; }
// Defensive: ensure the IELTS bucket shape exists even for mocks created
// before the IELTS sanitizer ran (or imported from an older cloud doc).
if(!m.questions || typeof m.questions !== 'object') m.questions = {};
if(!Array.isArray(m.questions.part1)) m.questions.part1 = [];
if(!Array.isArray(m.questions.part2)) m.questions.part2 = [];
if(!Array.isArray(m.questions.part3)) m.questions.part3 = [];
_ieltsEditPart = 1;
_ieltsEditQId = null;
_uploadedAudio=''; _uploadedAudioName='';
_uploadedImg=''; _uploadedImgName='';
openModal(`
🎙️ IELTS SPEAKING EDITOR — ${escHtml(m.name)}
Part 1: 30s per question · Part 2: 1 min prep + 2 min speak · Part 3: 60s per question
Each question records for up to ${cap}. STOP button is enabled after 5 seconds. ${partNum===1 ? 'Questions are audio-only to the student — no text is shown on screen.' : 'Questions are audio-only to the student — no text is shown.'} Text is for admin reference only.
${list.length} question${list.length===1?'':'s'} in Part ${partNum}
${list.length === 0
? '
No questions yet. Click + ADD QUESTION to start.
'
: rows}
`;
}
function ieltsAddQ(){
_ieltsEditQId = '__new__';
renderIeltsQuestionForm(null);
}
function ieltsEditQ(qId){
_ieltsEditQId = qId;
const m = DB.mocks.find(x => x.id === window._editMockId);
const bucketKey = (_ieltsEditPart === 1) ? 'part1' : 'part3';
const q = (m.questions[bucketKey] || []).find(x => x.id === qId);
if(!q){ showToast('Question not found'); ieltsPartTab(_ieltsEditPart); return; }
renderIeltsQuestionForm(q);
}
// Inline editing form for a single Part 1 / Part 3 question.
// Reuses the same audio upload pipeline as Multilevel.
function renderIeltsQuestionForm(existing){
_uploadedAudio=''; _uploadedAudioName='';
const cont = document.getElementById('ieltsEdContent');
if(!cont) return;
const isNew = !existing;
cont.innerHTML = `
${isNew ? '+ NEW QUESTION' : '✏ EDIT QUESTION'} — Part ${_ieltsEditPart}
🎵
${existing&&existing.audio?'✓ '+escHtml(existing.audioName||'Audio saved'):'CLICK TO UPLOAD AUDIO'}
`;
}
function ieltsSaveQ(){
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ showToast('Mock not found'); return; }
const bucketKey = (_ieltsEditPart === 1) ? 'part1' : 'part3';
if(!Array.isArray(m.questions[bucketKey])) m.questions[bucketKey] = [];
const text = (document.getElementById('iq_text')||{}).value || '';
const isNew = (_ieltsEditQId === '__new__');
// Need either text OR audio — pure empty saves are useless
if(!text.trim() && !_uploadedAudio && (isNew || !(_ieltsEditQId && (m.questions[bucketKey].find(q => q.id === _ieltsEditQId)||{}).audio))){
showToast('Enter question text or upload audio');
return;
}
if(isNew){
const obj = {
id: 'q_'+Date.now()+'_'+Math.random().toString(36).slice(2,8),
text: text.trim(),
audio: _uploadedAudio || '',
audioName: _uploadedAudioName || ''
};
m.questions[bucketKey].push(obj);
} else {
const q = m.questions[bucketKey].find(x => x.id === _ieltsEditQId);
if(!q){ showToast('Question not found'); ieltsPartTab(_ieltsEditPart); return; }
q.text = text.trim();
if(_uploadedAudio){ q.audio = _uploadedAudio; q.audioName = _uploadedAudioName; }
}
saveDB();
showToast(isNew ? '✓ Question added' : '✓ Question saved');
_uploadedAudio=''; _uploadedAudioName='';
_ieltsEditQId = null;
ieltsPartTab(_ieltsEditPart);
}
function ieltsDeleteQ(qId){
if(!confirm('Delete this question?')) return;
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ return; }
const bucketKey = (_ieltsEditPart === 1) ? 'part1' : 'part3';
m.questions[bucketKey] = (m.questions[bucketKey] || []).filter(q => q.id !== qId);
saveDB();
showToast('Deleted');
ieltsPartTab(_ieltsEditPart);
}
// Part 2 — single cue card. IELTS shape: topic line + 3-4 "you should say:" bullets,
// optional supporting image. Audio (the examiner reading the card aloud) is optional.
function renderIeltsP2Editor(){
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ return; }
if(!Array.isArray(m.questions.part2)) m.questions.part2 = [];
// Use the first (and only) cue card. If none exists, render empty form.
const card = m.questions.part2[0] || {};
_uploadedAudio=''; _uploadedAudioName='';
_uploadedImg=''; _uploadedImgName='';
const bullets = Array.isArray(card.bullets) ? card.bullets : [];
// 4 input slots (admin may leave any blank)
const bulletInputs = [0,1,2,3].map(i => `
Student gets 1 minute prep, then up to 2 minutes speaking. STOP button enables after 5s. Hard cap fires the warning audio and moves on.
YOU SHOULD SAY: — up to 4 bullets
${bulletInputs}
🎵
${card.audio?'✓ '+escHtml(card.audioName||'Audio saved'):'CLICK TO UPLOAD AUDIO'}
🖼
${card.img?'✓ Image saved':'CLICK TO UPLOAD IMAGE'}
${card.img?``:''}
${card.topic || card.audio ? `` : ''}
`;
}
function ieltsSaveP2(){
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ return; }
const topic = (document.getElementById('ip2_topic')||{}).value.trim();
const bullets = [0,1,2,3].map(i => ((document.getElementById('ip2_bullet_'+i)||{}).value||'').trim()).filter(Boolean);
if(!topic){ showToast('Topic is required'); return; }
const existing = (m.questions.part2 && m.questions.part2[0]) || {};
const card = {
id: existing.id || ('q_'+Date.now()+'_'+Math.random().toString(36).slice(2,8)),
topic,
bullets,
audio: _uploadedAudio || existing.audio || '',
audioName: _uploadedAudioName || existing.audioName || '',
img: _uploadedImg || existing.img || '',
imgName: _uploadedImgName || existing.imgName || ''
};
m.questions.part2 = [card];
saveDB();
_uploadedAudio=''; _uploadedAudioName='';
_uploadedImg=''; _uploadedImgName='';
showToast('✓ Cue card saved');
renderIeltsP2Editor();
}
function ieltsClearP2(){
if(!confirm('Clear the cue card?')) return;
const m = DB.mocks.find(x => x.id === window._editMockId);
if(!m){ return; }
m.questions.part2 = [];
saveDB();
showToast('Cleared');
renderIeltsP2Editor();
}
/* Map Q number → part bucket and behaviour */
function qNumToMeta(qNum){
// Q1,Q2,Q3 → intro (Part 1.1, no image)
// Q4 → part1 (Part 1.2, 2 images, 10s/45s)
// Q5,Q6 → part1 (Part 1.2, no image, 5s/30s)
// Q7 → part2 (1 image)
// Q8 → part3 (no image)
if(qNum<=3) return {partId:'intro', isQ4:false, isP2:false, isP3:false, hasImg:false, label:`Q${qNum} — Part 1.1`};
if(qNum===4) return {partId:'part1', isQ4:true, isP2:false, isP3:false, hasImg:true, label:'Q4 — Part 1.2 (10s prep, 45s answer, 2 images)'};
if(qNum<=6) return {partId:'part1', isQ4:false, isP2:false, isP3:false, hasImg:false, label:`Q${qNum} — Part 1.2`};
if(qNum===7) return {partId:'part2', isQ4:false, isP2:true, isP3:false, hasImg:true, label:'Q7 — Part 2 (3 questions + 1 picture, 1min prep, 2min speak)'};
if(qNum===8) return {partId:'part3', isQ4:false, isP2:false, isP3:true, hasImg:false, label:'Q8 — Part 3 (For/Against table, 1min prep, 2min speak)'};
return {partId:'intro',isQ4:false,isP2:false,isP3:false,hasImg:false,label:'Q'+qNum};
}
function edQTab(qNum){
// highlight active tab
for(let i=1;i<=8;i++){
const t=document.getElementById('qtab-'+i);
if(t) t.classList.toggle('act',i===qNum);
}
window._edQNum=qNum;
const meta=qNumToMeta(qNum);
window._edPart=meta.partId;
_uploadedAudio='';_uploadedAudioName='';
_uploadedImg='';_uploadedImgName='';
_uploadedImg2='';_uploadedImg2Name='';
const m=DB.mocks.find(x=>x.id===window._editMockId);
if(!m){
// Mock was deleted (possibly from another tab via cloud sync). Go back to mocks list.
showToast('Mock was deleted');
aTab('mocks');
return;
}
if(!m.questions || typeof m.questions !== 'object') m.questions={};
if(!m.questions[meta.partId]) m.questions[meta.partId]=[];
// For intro Q1-Q3: show only the Nth intro question slot
// For part1 Q4: show only part1[0], Q5=part1[1], Q6=part1[2]
// For part2/part3: show their single question
const cont=document.getElementById('edContent');
// figure out which specific question index within the bucket
let bucketIdx=0;
if(qNum<=3) bucketIdx=qNum-1; // intro[0],[1],[2]
else if(qNum===4) bucketIdx=0; // part1[0]
else if(qNum===5) bucketIdx=1; // part1[1]
else if(qNum===6) bucketIdx=2; // part1[2]
window._edBucketIdx=bucketIdx;
const {partId,isQ4,isP2,isP3}=meta;
const allQs=m.questions[partId]||[];
const existing=(allQs[bucketIdx] && typeof allQs[bucketIdx]==='object') ? allQs[bucketIdx] : null;
cont.innerHTML=`
${meta.label}
${isP2?'Enter 3 questions (one per line). Upload the picture shown during prep and speaking.':
isP3?'Enter the topic statement, then add each FOR and AGAINST point in its own field below.':
isQ4?'This question shows 2 pictures side by side. 10 second prep, 45 second answer.':
'No image for this question.'}
${isP3?`
FOR — up to 3 arguments
${[0,1,2].map(n=>``).join('')}
AGAINST — up to 3 arguments
${[0,1,2].map(n=>``).join('')}
`
:isP2?`
`
:`
`}
🎵
${existing&&existing.audio?'✓ '+escHtml(existing.audioName||'Audio saved'):'CLICK TO UPLOAD AUDIO'}
${meta.hasImg?`
${existing&&existing.img?'✅':''}
${existing&&existing.img?'✓ Image saved':'CLICK TO UPLOAD IMAGE'}
${existing&&existing.img?``:''}
`:''}
${isQ4?`
${existing&&existing.img2?'✅':''}
${existing&&existing.img2?'✓ Image 2 saved':'CLICK TO UPLOAD 2ND IMAGE'}
${existing&&existing.img2?``:''}
`;
}
function generateLink(){
const mockId=document.getElementById('linkMockSel').value;
const note=document.getElementById('linkNote').value.trim();
if(!mockId){showToast('Select a mock!');return;}
const token=Array.from(crypto.getRandomValues(new Uint8Array(24))).map(b=>b.toString(16).padStart(2,'0')).join('');
DB.links.push({id:'l_'+Date.now(),mockId,note,token,used:false,createdAt:new Date().toISOString(),usedAt:null});
saveDB();
const url=window.location.origin+window.location.pathname+'?token='+token;
document.getElementById('genLinkText').textContent=url;
document.getElementById('genLinkBox').classList.remove('hidden');
window._lastUrl=url;showToast('Link generated!');
}
function copyLink(){
const txt=window._lastUrl||'';
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(()=>showToast('Copied!')).catch(()=>fallbackCopy(txt));
} else fallbackCopy(txt);
}
function copyTokenLink(t){
const txt=window.location.origin+window.location.pathname+'?token='+t;
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(()=>showToast('Copied!')).catch(()=>fallbackCopy(txt));
} else fallbackCopy(txt);
}
function fallbackCopy(txt){
try{
const ta=document.createElement('textarea');
ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('Copied!');
}catch(e){showToast('Copy failed — select manually');}
}
function deleteLink(id){DB.links=DB.links.filter(l=>l.id!==id);saveDB();aTab('links');showToast('Deleted');}
function clearUsedLinks(){
if(!confirm('Clear all used links?'))return;
DB.links=DB.links.filter(l=>!l.used);saveDB();aTab('links');showToast('Cleared');
}
/* intros */
function renderAIntros(){
if(!DB.intros) DB.intros={};
const parts=[
// Multilevel intros (unchanged)
{key:'p11', label:'PART 1.1 INTRO', desc:'Multilevel — shown before Q1–Q3 (questions about yourself)', group:'multilevel'},
{key:'p12', label:'PART 1.2 INTRO', desc:'Multilevel — shown before Q4–Q6 (picture questions)', group:'multilevel'},
{key:'p2', label:'PART 2 INTRO', desc:'Multilevel — shown before Part 2 cue card', group:'multilevel'},
{key:'p3', label:'PART 3 INTRO', desc:'Multilevel — shown before Part 3 topic', group:'multilevel'},
// IELTS intros (new — independent slots)
{key:'ielts_p1', label:'IELTS PART 1 INTRO', desc:'IELTS — shown before the interview questions', group:'ielts'},
{key:'ielts_p2', label:'IELTS PART 2 INTRO', desc:'IELTS — shown before the cue card', group:'ielts'},
{key:'ielts_p3', label:'IELTS PART 3 INTRO', desc:'IELTS — shown before the discussion questions', group:'ielts'},
{key:'ielts_warn', label:'IELTS AUTO-STOP WARNING',
desc:'IELTS — plays automatically when a recording hits its time cap (30s P1, 2 min P2, 60s P3). Same audio is used for all three parts. Without this, the recording still stops cleanly but no sound plays. Recommended: a short calm voice line such as "Time is up. Moving on."',
group:'ielts', warning:true}
];
let html = `
PART INTRODUCTIONS
For each part: upload an audio recording (examiner's voice), and optionally edit the on-screen text the student reads. The audio is what plays aloud; the text is just visual. Multilevel and IELTS keep separate intros.
`;
// Section header for Multilevel
html += `
▸ MULTILEVEL
`;
parts.filter(p => p.group === 'multilevel').forEach(p => { html += renderOneIntroCard(p); });
// Section header for IELTS
html += `
▸ IELTS
`;
parts.filter(p => p.group === 'ielts').forEach(p => { html += renderOneIntroCard(p); });
return html;
}
// One intro card — used for both Multilevel and IELTS intros plus the IELTS warning audio.
function renderOneIntroCard(p){
const intro = (DB.intros && DB.intros[p.key]) || {};
// For the IELTS warning specifically, the text field is conceptually a label
// (not shown to students — they just hear the audio). We still let admin edit
// it for documentation, but with a different help label.
const isWarn = !!p.warning;
return `
${p.label}${isWarn && !intro.audio ? ' ⚠ NOT YET SET':''}
${p.desc}
🎵
${intro.audio?'✓ '+escHtml(intro.audioName||'Audio saved'):'CLICK TO UPLOAD AUDIO'}
';
}
function addContact(key,placeholder){
if(!DB.contacts) DB.contacts={telegrams:[],phones:[],emails:[]};
if(!DB.contacts[key]) DB.contacts[key]=[];
DB.contacts[key].push('');
saveDB();aTab('contact');
}
function removeContact(key,idx){
if(!DB.contacts||!DB.contacts[key]) return;
DB.contacts[key].splice(idx,1);
saveDB();aTab('contact');showToast('Removed');
}
function updateContact(key,idx,val){
if(!DB.contacts||!DB.contacts[key]) return;
DB.contacts[key][idx]=val.trim();
saveDB();showToast('Saved!');
}
function saveContact(){
saveDB();showToast('Contacts saved!');
}
/* privacy policy admin */
function renderAPrivacy(){
const p = DB.privacyPolicy || {};
const body = p.body || _defaultPrivacyPolicy();
const updated = p.updatedAt ? new Date(p.updatedAt).toLocaleString() : '(never edited — showing default)';
return `
PRIVACY POLICY
This text appears on the public /privacy page. Students see a link to it from the consent screen before recording starts. Edit carefully — this is a legal document.
LAST UPDATED: ${escHtml(updated)}
`;
}
function savePrivacy(){
const body = document.getElementById('privacy_body').value;
if(!body.trim()){ showToast('Privacy policy cannot be empty'); return; }
DB.privacyPolicy = { body, updatedAt: new Date().toISOString() };
saveDB();
showToast('Privacy policy saved!');
aTab('privacy');
}
function resetPrivacyToDefault(){
if(!confirm('Reset privacy policy to the default text?')) return;
DB.privacyPolicy = { body: _defaultPrivacyPolicy(), updatedAt: new Date().toISOString() };
saveDB();
aTab('privacy');
}
/* settings */
function renderASettings(){
return `
SPEAKING AI ASSESSMENT SECTION
The home-page section where students enter their own question + answer for instant AI assessment. Toggle whether it's visible, and whether each track requires a promocode (asked when the student presses Record).
When a track is set to paid, students are asked for a promocode the moment they press Record (uses the same promocodes above, "AI uses" quota). Uncheck to make it free.
GOOGLE ADS (ADSENSE)
Once your Google AdSense account is approved, paste your Publisher ID here (looks like ca-pub-1234567890123456). Ads then appear on the AI Assessment feedback page and Google pays you directly. Leave blank to show no ads.
${DB.settings.adsensePublisherId?'✓ Ads are active.':'No ID set — ads are currently hidden.'}
TELEGRAM CONTACT
Shown to users who click on a paid mock.
PROMOCODES
Create access codes that students enter to unlock paid mocks and/or AI assessments. Each code has a quota; usage is tracked in Firestore (tamper-proof).
⚠️ AI SAFETY CONTROLS
Emergency kill switch for the AI cron processor. If costs spiral, toggle this OFF to immediately stop the background queue from running AI calls.
Queue Processor
Loading…
CHANGE ADMIN PASSWORD
BACKUP & RECOVERY
If your mocks have disappeared due to a sync glitch, you can restore them here. The browser keeps a backup of the last known good state.
STORAGE CLEANUP
Find unused audio/image files in Supabase that are no longer referenced by any mock. Deleting them frees up storage space.
DANGER ZONE
Reset all data to factory defaults.
`;
}
function restoreFromBackup(){
try{
const raw=localStorage.getItem(SK+'_backup');
if(!raw){ showToast('No backup found'); return; }
const backup=JSON.parse(raw);
const mockCount=(backup.mocks||[]).filter(m=>m&&m.id).length;
if(mockCount===0){ showToast('Backup is empty'); return; }
if(!confirm('Restore '+mockCount+' mock(s) from local backup? This will replace current data.')) return;
DB=sanitizeDB(backup);
saveDB();
aTab('mocks');
showToast('Restored '+mockCount+' mock(s) from backup');
}catch(e){ showToast('Restore failed: '+e.message); }
}
function exportBackupFile(){
try{
const data=JSON.stringify(DB,null,2);
const blob=new Blob([data],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download='matrixmock-backup-'+new Date().toISOString().slice(0,10)+'.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(url),1000);
showToast('Backup downloaded');
}catch(e){ showToast('Export failed: '+e.message); }
}
function importBackupFile(ev){
const file=ev.target.files[0];
if(!file)return;
const reader=new FileReader();
reader.onload=()=>{
try{
const data=JSON.parse(reader.result);
const mockCount=(data.mocks||[]).filter(m=>m&&m.id).length;
if(!confirm('Import '+mockCount+' mock(s) from this file? This will replace current data.')) return;
DB=sanitizeDB(data);
saveDB();
aTab('mocks');
showToast('Imported '+mockCount+' mock(s)');
}catch(e){ showToast('Invalid backup file'); }
ev.target.value='';
};
reader.readAsText(file);
}
function saveTg(){
let v=document.getElementById('tgIn').value.trim();
if(!v){ showToast('Enter a Telegram link or username'); return; }
// Auto-fix common formats: @user or user → https://t.me/user
if(/^@/.test(v)) v='https://t.me/'+v.slice(1);
else if(!/^https?:\/\//i.test(v) && !/^t\.me\//i.test(v)){
// Plain username without scheme
if(/^[a-zA-Z0-9_]{3,}$/.test(v)) v='https://t.me/'+v;
} else if(/^t\.me\//i.test(v)) v='https://'+v;
DB.settings.telegramHandle=v;
saveDB();
document.getElementById('tgIn').value=v;
showToast('Saved!');
}
function saveAiAssessSettings(){
if(!DB.settings.aiAssess) DB.settings.aiAssess = {};
const en = document.getElementById('aiaEnabled');
const ml = document.getElementById('aiaPaidML');
const ie = document.getElementById('aiaPaidIELTS');
if(en) DB.settings.aiAssess.enabled = en.checked;
if(ml) DB.settings.aiAssess.paidMultilevel = ml.checked;
if(ie) DB.settings.aiAssess.paidIelts = ie.checked;
saveDB();
showToast('Saved!');
}
function saveAdsenseId(){
const el = document.getElementById('adsenseIdIn');
let v = el ? el.value.trim() : '';
// Light validation: AdSense publisher IDs start with "ca-pub-".
if(v && !/^ca-pub-\d{6,}$/.test(v)){
showToast('That does not look like a ca-pub-... ID. Saved anyway — double-check it.');
}
DB.settings.adsensePublisherId = v;
saveDB();
showToast('Saved!');
aTab('settings');
}
/* ════════════════════════════════════════
PROMOCODE ADMIN
════════════════════════════════════════ */
function generatePromoCode(){
// 4 letters + 4 digits separated by dash, e.g. ABCD-1234
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // omit I, O for clarity
const digits = '23456789'; // omit 0, 1 for clarity
let part1 = '', part2 = '';
for (let i = 0; i < 4; i++) part1 += letters[Math.floor(Math.random()*letters.length)];
for (let i = 0; i < 4; i++) part2 += digits[Math.floor(Math.random()*digits.length)];
return part1 + '-' + part2;
}
async function createPromocode(){
if(!window._fsPromoCreate){ showToast('Cloud not connected. Wait a moment.'); return; }
const mocksLimit = Math.max(0, parseInt(document.getElementById('promoMocksLimit').value)||0);
const aiLimit = Math.max(0, parseInt(document.getElementById('promoAiLimit').value)||0);
const note = (document.getElementById('promoNote').value||'').trim().slice(0, 100);
if(mocksLimit === 0 && aiLimit === 0){
showToast('Set at least one quota > 0');
return;
}
// Try up to 5 times in the very unlikely case of collision
for(let attempt=0; attempt<5; attempt++){
const code = generatePromoCode();
try {
await window._fsPromoCreate(code, {
mocksLimit, aiLimit, notes: note
});
showToast('✓ Code created: ' + code);
document.getElementById('promoNote').value = '';
loadPromocodesList();
// Auto-copy to clipboard for convenience
try { await navigator.clipboard.writeText(code); showToast('✓ Code created & copied: ' + code); } catch(e){}
return;
} catch(err){
if(String(err.message||'').includes('already exists')) continue; // retry
showToast('Error: ' + (err.message||err));
return;
}
}
showToast('Could not generate a unique code. Try again.');
}
async function loadPromocodesList(){
const box = document.getElementById('promoListBox');
if(!box) return;
box.innerHTML = '