feat(v3): restructure project + add complete frontend

- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,423 @@
/** @odoo-module */
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
const AUTOSAVE_INTERVAL = 10000;
export class ExamApp extends Component {
static template = "encoach_exam_session.ExamApp";
static props = {};
setup() {
this.rootRef = useRef("root");
this.state = useState({
loading: true,
examData: null,
currentSectionIdx: 0,
currentQuestionIdx: 0,
answers: {},
flagged: {},
timeRemaining: 0,
totalTimeRemaining: 0,
submitted: false,
showReview: false,
showConfirmSubmit: false,
submitMessage: "",
});
this.timerInterval = null;
this.autosaveInterval = null;
onWillStart(async () => {
await this.loadExamData();
});
onMounted(() => {
this.startTimers();
this.enterFullscreen();
this.loadFromLocalStorage();
document.addEventListener("visibilitychange", this._onVisibility);
});
onWillUnmount(() => {
this.stopTimers();
document.removeEventListener("visibilitychange", this._onVisibility);
});
}
get attemptId() {
const el = document.getElementById("exam_session_app");
return el ? parseInt(el.dataset.attemptId) : 0;
}
get examId() {
const el = document.getElementById("exam_session_app");
return el ? parseInt(el.dataset.examId) : 0;
}
getToken() {
return (
document.cookie
.split(";")
.find((c) => c.trim().startsWith("session_id="))
?.split("=")[1] || ""
);
}
// ── Data Loading ──────────────────────────────────────────────────
async loadExamData() {
try {
const response = await fetch(`/api/exam/${this.examId}/session`, {
headers: { Authorization: `Bearer ${this.getToken()}` },
});
const data = await response.json();
this.state.examData = data;
this.state.timeRemaining =
data.sections?.[0]?.time_limit_sec || 3600;
this.state.totalTimeRemaining = data.total_time_sec || 7200;
if (data.saved_answers) {
for (const ans of data.saved_answers) {
this.state.answers[ans.question_id] = ans.answer;
}
}
this.state.loading = false;
} catch (e) {
console.error("Failed to load exam data:", e);
}
}
// ── Timers ────────────────────────────────────────────────────────
startTimers() {
this.timerInterval = setInterval(() => {
if (this.state.timeRemaining > 0) {
this.state.timeRemaining--;
this.state.totalTimeRemaining--;
} else {
this.autoSubmitSection();
}
}, 1000);
this.autosaveInterval = setInterval(
() => this.autosave(),
AUTOSAVE_INTERVAL
);
}
stopTimers() {
if (this.timerInterval) clearInterval(this.timerInterval);
if (this.autosaveInterval) clearInterval(this.autosaveInterval);
}
enterFullscreen() {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => {});
}
}
formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
get timerClass() {
if (this.state.timeRemaining < 60) return "ec-timer ec-timer--critical";
if (this.state.timeRemaining < 300) return "ec-timer ec-timer--warning";
return "ec-timer";
}
// ── Section / Question Navigation ─────────────────────────────────
get currentSection() {
return this.state.examData?.sections?.[this.state.currentSectionIdx];
}
get currentQuestion() {
return this.currentSection?.questions?.[this.state.currentQuestionIdx];
}
get totalQuestions() {
return this.currentSection?.questions?.length || 0;
}
get answeredCount() {
const section = this.currentSection;
if (!section) return 0;
return section.questions.filter(
(q) => this.state.answers[q.id] !== undefined
).length;
}
get sections() {
return this.state.examData?.sections || [];
}
get questionDots() {
const section = this.currentSection;
if (!section) return [];
return section.questions.map((q, idx) => ({
idx,
id: q.id,
answered: this.state.answers[q.id] !== undefined,
flagged: !!this.state.flagged[q.id],
current: idx === this.state.currentQuestionIdx,
}));
}
get hasPrev() {
return this.state.currentQuestionIdx > 0;
}
get hasNext() {
return this.state.currentQuestionIdx < this.totalQuestions - 1;
}
get isLastSection() {
return (
this.state.currentSectionIdx >=
(this.state.examData?.sections?.length || 1) - 1
);
}
nextQuestion() {
if (this.state.currentQuestionIdx < this.totalQuestions - 1) {
this.state.currentQuestionIdx++;
}
}
prevQuestion() {
if (this.state.currentQuestionIdx > 0) {
this.state.currentQuestionIdx--;
}
}
goToQuestion(idx) {
this.state.currentQuestionIdx = idx;
this.state.showReview = false;
}
switchSection(idx) {
this.state.currentSectionIdx = idx;
this.state.currentQuestionIdx = 0;
const section = this.state.examData?.sections?.[idx];
if (section?.time_limit_sec) {
this.state.timeRemaining = section.time_limit_sec;
}
}
nextSection() {
const sections = this.state.examData?.sections || [];
if (this.state.currentSectionIdx < sections.length - 1) {
this.state.currentSectionIdx++;
this.state.currentQuestionIdx = 0;
this.state.timeRemaining =
this.currentSection?.time_limit_sec || 3600;
}
}
// ── Answer Management ─────────────────────────────────────────────
setAnswer(questionId, value) {
this.state.answers[questionId] = value;
this.saveToLocalStorage();
}
onAnswer(ev) {
if (ev.detail) {
this.setAnswer(ev.detail.questionId, ev.detail.value);
}
}
toggleFlag() {
const q = this.currentQuestion;
if (!q) return;
this.state.flagged[q.id] = !this.state.flagged[q.id];
}
get isCurrentFlagged() {
const q = this.currentQuestion;
return q ? !!this.state.flagged[q.id] : false;
}
// ── LocalStorage Persistence ──────────────────────────────────────
saveToLocalStorage() {
try {
localStorage.setItem(
`exam_${this.attemptId}`,
JSON.stringify({
answers: this.state.answers,
flagged: this.state.flagged,
currentSectionIdx: this.state.currentSectionIdx,
currentQuestionIdx: this.state.currentQuestionIdx,
timeRemaining: this.state.timeRemaining,
})
);
} catch (e) {
// storage full or unavailable
}
}
loadFromLocalStorage() {
try {
const saved = localStorage.getItem(`exam_${this.attemptId}`);
if (saved) {
const data = JSON.parse(saved);
Object.assign(this.state.answers, data.answers || {});
Object.assign(this.state.flagged, data.flagged || {});
}
} catch (e) {
// corrupt data
}
}
// ── Server Autosave ───────────────────────────────────────────────
async autosave() {
try {
const answers = Object.entries(this.state.answers).map(
([qid, ans]) => ({
question_id: parseInt(qid),
answer: ans,
})
);
await fetch(`/api/exam/${this.examId}/autosave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
attempt_id: this.attemptId,
answers,
current_section: this.currentSection?.skill || "",
time_remaining_sec: this.state.timeRemaining,
}),
});
} catch (e) {
console.warn("Autosave failed:", e);
}
}
// ── Visibility tracking (tab-switch guard) ────────────────────────
_onVisibility = () => {
if (document.visibilityState === "visible") {
this.autosave();
}
};
// ── Submit Flow ───────────────────────────────────────────────────
autoSubmitSection() {
const sections = this.state.examData?.sections || [];
if (this.state.currentSectionIdx < sections.length - 1) {
this.nextSection();
} else {
this.submitExam();
}
}
showReviewPanel() {
this.state.showReview = true;
}
hideReviewPanel() {
this.state.showReview = false;
}
confirmSubmit() {
this.state.showConfirmSubmit = true;
}
cancelSubmit() {
this.state.showConfirmSubmit = false;
}
async submitExam() {
this.stopTimers();
this.state.submitted = true;
this.state.showConfirmSubmit = false;
this.state.submitMessage = "Submitting your answers...";
try {
const answers = Object.entries(this.state.answers).map(
([qid, ans]) => ({
question_id: parseInt(qid),
answer: ans,
})
);
const response = await fetch(`/api/exam/${this.examId}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
attempt_id: this.attemptId,
answers,
}),
});
await response.json();
localStorage.removeItem(`exam_${this.attemptId}`);
this.state.submitMessage =
"Exam submitted successfully! Redirecting...";
setTimeout(() => {
window.location.href = `/my/exam/${this.attemptId}/results`;
}, 2000);
} catch (e) {
console.error("Submit failed:", e);
this.state.submitMessage =
"Submission failed. Please try again.";
this.state.submitted = false;
}
}
// ── Review summary helpers ────────────────────────────────────────
get reviewSections() {
const sections = this.state.examData?.sections || [];
return sections.map((section, sIdx) => ({
...section,
sIdx,
questions: section.questions.map((q, qIdx) => ({
...q,
qIdx,
sIdx,
answered: this.state.answers[q.id] !== undefined,
flagged: !!this.state.flagged[q.id],
})),
}));
}
get totalAnswered() {
return Object.keys(this.state.answers).length;
}
get totalAllQuestions() {
const sections = this.state.examData?.sections || [];
return sections.reduce((sum, s) => sum + (s.questions?.length || 0), 0);
}
get totalFlagged() {
return Object.values(this.state.flagged).filter(Boolean).length;
}
reviewGoTo(sIdx, qIdx) {
this.state.currentSectionIdx = sIdx;
this.state.currentQuestionIdx = qIdx;
this.state.showReview = false;
}
}
// ── Auto-mount on page load ───────────────────────────────────────────
function mountExamApp() {
const target = document.getElementById("exam_session_app");
if (target) {
const { mount } = owl;
mount(ExamApp, target);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountExamApp);
} else {
mountExamApp();
}

View File

@@ -0,0 +1,368 @@
(function () {
"use strict";
const app = document.getElementById("exam_session_app");
if (!app) return;
const ATTEMPT_ID = parseInt(app.dataset.attemptId);
const EXAM_ID = parseInt(app.dataset.examId);
const SESSION_TOKEN = app.dataset.sessionToken;
let sections = [];
let currentSection = 0;
let currentQuestion = 0;
let answers = {};
let totalTime = 0;
let timeLeft = 0;
let timerInterval = null;
const sidebar = document.getElementById("sidebar");
const mainContent = document.getElementById("mainContent");
const timerEl = document.getElementById("timer");
const progressBar = document.getElementById("progressBar");
async function fetchJSON(url, options) {
options = options || {};
options.headers = Object.assign(
{ "Content-Type": "application/json" },
options.headers || {}
);
const res = await fetch(url, options);
return res.json();
}
async function loadSession() {
const data = await fetchJSON("/api/exam/" + EXAM_ID + "/session", {
headers: { Authorization: "Bearer " + SESSION_TOKEN },
});
if (data.error) {
mainContent.innerHTML =
'<div class="ec-card"><h3>Error</h3><p>' +
data.error +
"</p></div>";
return;
}
sections = data.sections || [];
totalTime = (data.total_time_min || 30) * 60;
timeLeft = totalTime;
sections.forEach(function (sec) {
(sec.questions || []).forEach(function (q) {
if (q.saved_answer) answers[q.id] = q.saved_answer;
});
});
buildSidebar();
renderQuestion();
startTimer();
}
function buildSidebar() {
var html = "";
sections.forEach(function (sec, si) {
html += '<div class="section-title">' + sec.title + "</div><div>";
(sec.questions || []).forEach(function (q, qi) {
var cls = answers[q.id] ? "answered" : "";
var act =
si === currentSection && qi === currentQuestion
? "active"
: "";
html +=
'<button class="q-btn ' +
cls +
" " +
act +
'" data-si="' +
si +
'" data-qi="' +
qi +
'">' +
(si * 5 + qi + 1) +
"</button>";
});
html += "</div>";
});
sidebar.innerHTML = html;
sidebar.querySelectorAll(".q-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
jumpTo(
parseInt(this.dataset.si),
parseInt(this.dataset.qi)
);
});
});
}
function renderQuestion() {
var sec = sections[currentSection];
if (!sec) return;
var q = sec.questions[currentQuestion];
if (!q) return;
var globalIdx =
sections
.slice(0, currentSection)
.reduce(function (a, s) {
return a + s.questions.length;
}, 0) +
currentQuestion +
1;
var totalQ = sections.reduce(function (a, s) {
return a + s.questions.length;
}, 0);
progressBar.style.width = (globalIdx / totalQ) * 100 + "%";
var optionsHtml = "";
if (q.options && q.options.length > 0) {
q.options.forEach(function (opt) {
var key = opt.key || opt.text;
var sel = answers[q.id] === key ? "selected" : "";
optionsHtml +=
'<label class="ec-option ' +
sel +
'" data-qid="' +
q.id +
'" data-key="' +
key +
'">' +
'<input type="radio" name="q' +
q.id +
'"' +
(sel ? " checked" : "") +
"/>" +
'<span class="ec-option-key">' +
(opt.key || "") +
"</span>" +
"<span>" +
(opt.text || key) +
"</span>" +
"</label>";
});
} else {
var val = answers[q.id] || "";
optionsHtml =
'<input type="text" class="form-control" id="textAnswer" value="' +
val +
'" placeholder="Type your answer…"/>';
}
var isFirst = currentSection === 0 && currentQuestion === 0;
var isLast =
currentSection === sections.length - 1 &&
currentQuestion === sec.questions.length - 1;
mainContent.innerHTML =
'<div class="ec-card">' +
'<div style="color:#64748B;font-size:.85rem;margin-bottom:12px;">' +
sec.title +
" \u2014 Question " +
(currentQuestion + 1) +
" of " +
sec.questions.length +
'<span style="float:right;color:var(--ec-primary);font-weight:600;">' +
(q.difficulty || "") +
" \u2022 " +
(q.marks || 1) +
" mark" +
((q.marks || 1) > 1 ? "s" : "") +
"</span></div>" +
'<div class="ec-question-stem">' +
q.stem +
"</div>" +
optionsHtml +
"</div>" +
'<div class="ec-nav">' +
'<button class="btn btn-outline-secondary" id="btnPrev"' +
(isFirst ? " disabled" : "") +
">\u2190 Previous</button>" +
(isLast
? '<button class="btn btn-success btn-lg" id="btnSubmit">Submit Exam</button>'
: '<button class="btn text-white" id="btnNext" style="background:var(--ec-primary)">Next \u2192</button>') +
"</div>";
mainContent.querySelectorAll(".ec-option").forEach(function (el) {
el.addEventListener("click", function () {
selectAnswer(
parseInt(this.dataset.qid),
this.dataset.key,
this
);
});
});
var textInput = document.getElementById("textAnswer");
if (textInput) {
textInput.addEventListener("change", function () {
answers[q.id] = this.value;
});
}
var btnPrev = document.getElementById("btnPrev");
if (btnPrev)
btnPrev.addEventListener("click", function () {
navigate(-1);
});
var btnNext = document.getElementById("btnNext");
if (btnNext)
btnNext.addEventListener("click", function () {
navigate(1);
});
var btnSubmit = document.getElementById("btnSubmit");
if (btnSubmit) btnSubmit.addEventListener("click", submitExam);
buildSidebar();
}
function selectAnswer(qid, key, el) {
answers[qid] = key;
el.closest(".ec-card")
.querySelectorAll(".ec-option")
.forEach(function (o) {
o.classList.remove("selected");
});
el.classList.add("selected");
buildSidebar();
}
function navigate(dir) {
var sec = sections[currentSection];
var next = currentQuestion + dir;
if (next >= 0 && next < sec.questions.length) {
currentQuestion = next;
} else if (dir > 0 && currentSection < sections.length - 1) {
currentSection++;
currentQuestion = 0;
} else if (dir < 0 && currentSection > 0) {
currentSection--;
currentQuestion = sections[currentSection].questions.length - 1;
}
renderQuestion();
autoSave();
}
function jumpTo(si, qi) {
currentSection = si;
currentQuestion = qi;
renderQuestion();
}
function startTimer() {
timerInterval = setInterval(function () {
timeLeft--;
if (timeLeft <= 0) {
clearInterval(timerInterval);
submitExam();
return;
}
var m = Math.floor(timeLeft / 60);
var s = timeLeft % 60;
timerEl.textContent =
String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
timerEl.className =
"ec-timer" +
(timeLeft < 120
? " danger"
: timeLeft < 300
? " warning"
: "");
}, 1000);
}
var saveTimeout;
function autoSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(function () {
var ansArr = Object.entries(answers).map(function (entry) {
return { question_id: parseInt(entry[0]), answer: entry[1] };
});
fetchJSON("/api/exam/" + EXAM_ID + "/autosave", {
method: "POST",
headers: { Authorization: "Bearer " + SESSION_TOKEN },
body: JSON.stringify({
attempt_id: ATTEMPT_ID,
answers: ansArr,
current_section:
sections[currentSection]
? sections[currentSection].skill
: "",
time_remaining_sec: timeLeft,
}),
});
}, 2000);
}
function submitExam() {
if (
!confirm(
"Are you sure you want to submit? You cannot change answers after submission."
)
)
return;
clearInterval(timerInterval);
mainContent.innerHTML =
'<div class="ec-card" style="text-align:center;padding:48px">' +
"<h2>Submitting\u2026</h2>" +
'<div class="spinner-border text-primary mt-3"></div></div>';
var ansArr = Object.entries(answers).map(function (entry) {
return { question_id: parseInt(entry[0]), answer: entry[1] };
});
fetchJSON("/api/exam/" + EXAM_ID + "/submit", {
method: "POST",
headers: { Authorization: "Bearer " + SESSION_TOKEN },
body: JSON.stringify({ attempt_id: ATTEMPT_ID, answers: ansArr }),
}).then(function (result) {
if (result.scores) {
showResults(result.scores);
} else {
mainContent.innerHTML =
'<div class="ec-card ec-results">' +
"<h2>Exam Submitted</h2>" +
"<p>Status: " +
result.status +
"</p>" +
"<p>Your results will be released soon.</p></div>";
}
sidebar.innerHTML =
'<div style="padding:16px;text-align:center;color:#64748B">Exam completed</div>';
});
}
function showResults(scores) {
var skills = ["listening", "reading", "writing", "speaking"].filter(
function (s) {
return scores[s] > 0;
}
);
var rows = skills
.map(function (s) {
return (
'<div class="ec-skill-row"><span style="text-transform:capitalize">' +
s +
"</span><strong>" +
scores[s].toFixed(1) +
"</strong></div>"
);
})
.join("");
mainContent.innerHTML =
'<div class="ec-card ec-results">' +
'<h2 style="margin-bottom:8px">Exam Complete!</h2>' +
'<div class="ec-band">' +
scores.overall.toFixed(1) +
"</div>" +
'<div style="color:#64748B;margin-bottom:24px">Overall Band Score \u2014 CEFR ' +
(scores.cefr || "").toUpperCase() +
"</div>" +
'<div style="max-width:400px;margin:0 auto;text-align:left">' +
rows +
"</div>" +
'<a href="/web" class="btn btn-primary mt-4">Return to Dashboard</a>' +
"</div>";
}
loadSession();
})();

View File

@@ -0,0 +1,199 @@
/** @odoo-module */
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
const SEM_THRESHOLD = 0.3;
const MAX_QUESTIONS = 40;
export class PlacementApp extends Component {
static template = "encoach_exam_session.PlacementApp";
static props = {};
setup() {
this.rootRef = useRef("root");
this.state = useState({
loading: true,
sessionId: 0,
currentQuestion: null,
answer: null,
questionsAnswered: 0,
theta: 0.0,
sem: 1.0,
completed: false,
cefrLevel: null,
submitting: false,
error: null,
});
onWillStart(async () => {
await this.startSession();
});
onMounted(() => {
this.enterFullscreen();
});
}
get sessionIdFromDom() {
const el = document.getElementById("placement_session_app");
return el ? parseInt(el.dataset.sessionId) : 0;
}
enterFullscreen() {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => {});
}
}
// ── Session Start ─────────────────────────────────────────────
async startSession() {
try {
const domSessionId = this.sessionIdFromDom;
if (domSessionId) {
this.state.sessionId = domSessionId;
await this.loadCurrentQuestion();
} else {
const response = await fetch("/api/placement/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await response.json();
this.state.sessionId = data.session_id;
this.state.currentQuestion = data.first_question;
}
this.state.loading = false;
} catch (e) {
this.state.error = "Failed to start placement test.";
this.state.loading = false;
}
}
async loadCurrentQuestion() {
try {
const response = await fetch(
`/api/placement/current?session_id=${this.state.sessionId}`
);
const data = await response.json();
if (data.question) {
this.state.currentQuestion = data.question;
this.state.questionsAnswered = data.questions_answered || 0;
this.state.theta = data.theta || 0.0;
this.state.sem = data.sem || 1.0;
}
} catch (e) {
console.error("Failed to load current question:", e);
}
}
// ── Answer Handling ───────────────────────────────────────────
setAnswer(questionId, value) {
this.state.answer = value;
}
get hasAnswer() {
return this.state.answer !== null && this.state.answer !== undefined && this.state.answer !== "";
}
async submitAnswer() {
if (!this.hasAnswer || this.state.submitting) return;
this.state.submitting = true;
try {
const response = await fetch("/api/placement/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: this.state.sessionId,
question_id: this.state.currentQuestion.id,
answer: this.state.answer,
}),
});
const data = await response.json();
this.state.theta = data.new_theta;
this.state.sem = data.new_sem;
this.state.questionsAnswered++;
if (data.completed) {
this.state.completed = true;
this.state.cefrLevel = data.cefr_level;
} else {
this.state.currentQuestion = data.next_question;
this.state.answer = null;
}
} catch (e) {
this.state.error = "Failed to submit answer. Please try again.";
} finally {
this.state.submitting = false;
}
}
// ── Progress Helpers ──────────────────────────────────────────
get progressPercent() {
const semProgress = Math.max(0, (1.0 - this.state.sem) / (1.0 - SEM_THRESHOLD));
const questionProgress = this.state.questionsAnswered / MAX_QUESTIONS;
return Math.min(Math.max(semProgress, questionProgress) * 100, 100);
}
get progressStyle() {
return `width: ${this.progressPercent}%`;
}
get questionType() {
return this.state.currentQuestion?.question_type || "mcq";
}
get questionOptions() {
const opts = this.state.currentQuestion?.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
selectOption(key) {
this.state.answer = key;
}
get cefrLabel() {
const map = {
pre_a1: "Pre-A1",
a1: "A1",
a2: "A2",
b1: "B1",
b2: "B2",
c1: "C1",
c2: "C2",
};
return map[this.state.cefrLevel] || this.state.cefrLevel?.toUpperCase() || "";
}
goToDashboard() {
window.location.href = "/my/dashboard";
}
}
// ── Auto-mount ────────────────────────────────────────────────────
function mountPlacementApp() {
const target = document.getElementById("placement_session_app");
if (target) {
const { mount } = owl;
mount(PlacementApp, target);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountPlacementApp);
} else {
mountPlacementApp();
}

View File

@@ -0,0 +1,345 @@
/** @odoo-module */
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
// ═══════════════════════════════════════════════════════════════════════
// ExamMCQ — Single-choice radio buttons
// ═══════════════════════════════════════════════════════════════════════
export class ExamMCQ extends Component {
static template = "encoach_exam_session.ExamMCQ";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
selectOption(optionKey) {
this.props.onAnswer(this.props.question.id, optionKey);
}
get options() {
const opts = this.props.question.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamMCQMulti — Multi-choice checkboxes
// ═══════════════════════════════════════════════════════════════════════
export class ExamMCQMulti extends Component {
static template = "encoach_exam_session.ExamMCQMulti";
static props = {
question: Object,
answer: { type: [Array, { value: undefined }], optional: true },
onAnswer: Function,
};
toggleOption(optionKey) {
const current = Array.isArray(this.props.answer)
? [...this.props.answer]
: [];
const idx = current.indexOf(optionKey);
if (idx >= 0) {
current.splice(idx, 1);
} else {
current.push(optionKey);
}
this.props.onAnswer(this.props.question.id, current);
}
isChecked(optionKey) {
return Array.isArray(this.props.answer) && this.props.answer.includes(optionKey);
}
get options() {
const opts = this.props.question.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamGapFill — Inline text inputs within a passage
// ═══════════════════════════════════════════════════════════════════════
export class ExamGapFill extends Component {
static template = "encoach_exam_session.ExamGapFill";
static props = {
question: Object,
answer: { type: [Object, { value: undefined }], optional: true },
onAnswer: Function,
};
onGapInput(gapIdx, ev) {
const current = this.props.answer ? { ...this.props.answer } : {};
current[gapIdx] = ev.target.value;
this.props.onAnswer(this.props.question.id, current);
}
get gaps() {
return this.props.question.gaps || [];
}
gapValue(gapIdx) {
return this.props.answer?.[gapIdx] || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamTFNG — True / False / Not Given selector
// ═══════════════════════════════════════════════════════════════════════
export class ExamTFNG extends Component {
static template = "encoach_exam_session.ExamTFNG";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
select(value) {
this.props.onAnswer(this.props.question.id, value);
}
get choices() {
return [
{ key: "true", label: "True" },
{ key: "false", label: "False" },
{ key: "not_given", label: "Not Given" },
];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamAudioRecorder — Record audio (speaking tasks)
// ═══════════════════════════════════════════════════════════════════════
export class ExamAudioRecorder extends Component {
static template = "encoach_exam_session.ExamAudioRecorder";
static props = {
question: Object,
answer: { type: [String, Object, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.state = useState({
recording: false,
recordedUrl: null,
duration: 0,
error: null,
});
this.mediaRecorder = null;
this.chunks = [];
this.durationInterval = null;
}
async startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
this.chunks = [];
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: "audio/webm" });
this.state.recordedUrl = URL.createObjectURL(blob);
this.props.onAnswer(this.props.question.id, {
type: "audio",
blob_url: this.state.recordedUrl,
duration: this.state.duration,
});
stream.getTracks().forEach((t) => t.stop());
};
this.mediaRecorder.start();
this.state.recording = true;
this.state.duration = 0;
this.durationInterval = setInterval(() => {
this.state.duration++;
}, 1000);
} catch (e) {
this.state.error = "Microphone access denied. Please allow microphone access.";
}
}
stopRecording() {
if (this.mediaRecorder && this.state.recording) {
this.mediaRecorder.stop();
this.state.recording = false;
if (this.durationInterval) clearInterval(this.durationInterval);
}
}
formatDuration(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamRichEditor — Writing task with rich text
// ═══════════════════════════════════════════════════════════════════════
export class ExamRichEditor extends Component {
static template = "encoach_exam_session.ExamRichEditor";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.editorRef = useRef("editor");
this.state = useState({
wordCount: this._countWords(this.props.answer || ""),
});
}
onInput(ev) {
const text = ev.target.value || "";
this.state.wordCount = this._countWords(text);
this.props.onAnswer(this.props.question.id, text);
}
_countWords(text) {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
get minWords() {
return this.props.question.min_words || 150;
}
get maxWords() {
return this.props.question.max_words || 300;
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamReadingPassage — Scrollable passage display
// ═══════════════════════════════════════════════════════════════════════
export class ExamReadingPassage extends Component {
static template = "encoach_exam_session.ExamReadingPassage";
static props = {
passage: { type: [Object, String, { value: undefined }], optional: true },
};
get passageTitle() {
if (typeof this.props.passage === "object") {
return this.props.passage?.title || "Reading Passage";
}
return "Reading Passage";
}
get passageContent() {
if (typeof this.props.passage === "object") {
return this.props.passage?.content || "";
}
return this.props.passage || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamNumericalInput — Number input with optional tolerance
// ═══════════════════════════════════════════════════════════════════════
export class ExamNumericalInput extends Component {
static template = "encoach_exam_session.ExamNumericalInput";
static props = {
question: Object,
answer: { type: [Number, String, { value: undefined }], optional: true },
onAnswer: Function,
};
onInput(ev) {
const val = ev.target.value;
this.props.onAnswer(this.props.question.id, val);
}
get tolerance() {
return this.props.question.tolerance;
}
get unit() {
return this.props.question.unit || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamCodeEditor — Code editing textarea
// ═══════════════════════════════════════════════════════════════════════
export class ExamCodeEditor extends Component {
static template = "encoach_exam_session.ExamCodeEditor";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.editorRef = useRef("codeEditor");
onMounted(() => {
if (this.editorRef.el) {
this.editorRef.el.addEventListener("keydown", this._onKeydown);
}
});
onWillUnmount(() => {
if (this.editorRef.el) {
this.editorRef.el.removeEventListener("keydown", this._onKeydown);
}
});
}
_onKeydown = (ev) => {
if (ev.key === "Tab") {
ev.preventDefault();
const ta = ev.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + 4;
this.props.onAnswer(this.props.question.id, ta.value);
}
};
onInput(ev) {
this.props.onAnswer(this.props.question.id, ev.target.value);
}
get language() {
return this.props.question.language || "python";
}
get starterCode() {
return this.props.question.starter_code || "";
}
}

View File

@@ -0,0 +1,302 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
ExamApp — Main full-screen exam orchestrator
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamApp">
<div class="ec-exam-fullscreen" t-ref="root">
<!-- Loading State -->
<t t-if="state.loading">
<div class="ec-loading">
<div class="ec-spinner"/>
<div class="ec-loading-text">Loading your exam...</div>
</div>
</t>
<!-- Submitted State -->
<t t-elif="state.submitted">
<div class="ec-submitted-overlay">
<div class="ec-submitted-icon"></div>
<div class="ec-submitted-text" t-esc="state.submitMessage"/>
</div>
</t>
<!-- Exam Active State -->
<t t-else="">
<!-- ── Top Bar ──────────────────────────────────────── -->
<div class="ec-topbar">
<div class="ec-topbar-title"
t-esc="state.examData?.title or 'Exam Session'"/>
<!-- Section tabs -->
<div class="ec-section-tabs">
<t t-foreach="sections" t-as="section" t-key="section_index">
<button t-att-class="'ec-section-tab' + (section_index === state.currentSectionIdx ? ' ec-section-tab--active' : '')"
t-on-click="() => this.switchSection(section_index)">
<t t-esc="section.name or section.skill or ('Section ' + (section_index + 1))"/>
</button>
</t>
</div>
<div class="ec-topbar-spacer"/>
<!-- Progress -->
<div class="ec-progress-info">
<t t-esc="answeredCount"/> / <t t-esc="totalQuestions"/> answered
</div>
<!-- Timer -->
<div t-att-class="timerClass">
<span class="ec-timer-icon"></span>
<span t-esc="formatTime(state.timeRemaining)"/>
</div>
</div>
<!-- ── Content Area ─────────────────────────────────── -->
<div class="ec-content">
<!-- Passage panel (only if current question has a passage) -->
<t t-if="currentQuestion?.passage">
<div class="ec-passage-panel">
<div class="ec-passage-title"
t-esc="currentQuestion.passage.title or 'Reading Passage'"/>
<div class="ec-passage-body"
t-esc="currentQuestion.passage.content or currentQuestion.passage"/>
</div>
</t>
<!-- Question panel -->
<div t-att-class="'ec-question-panel' + (currentQuestion?.passage ? '' : ' ec-question-panel--full')">
<t t-if="currentQuestion">
<!-- Question header -->
<div class="ec-question-header">
<div class="ec-question-number"
t-esc="state.currentQuestionIdx + 1"/>
<div class="ec-question-type"
t-esc="currentQuestion.question_type or 'question'"/>
</div>
<!-- Question stem -->
<div class="ec-question-stem"
t-esc="currentQuestion.stem"/>
<!-- Question Renderer (dispatched by type) -->
<t t-if="currentQuestion.question_type === 'mcq'">
<ExamMCQ question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'mcq_multi'">
<ExamMCQMulti question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'gap_fill'">
<ExamGapFill question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'tfng'">
<ExamTFNG question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'audio_recording'">
<ExamAudioRecorder question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'writing'">
<ExamRichEditor question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'numerical'">
<ExamNumericalInput question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'code'">
<ExamCodeEditor question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<!-- Fallback: plain MCQ -->
<t t-else="">
<ExamMCQ question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
</t>
</div>
</div>
<!-- ── Bottom Bar ───────────────────────────────────── -->
<div class="ec-bottombar">
<button class="ec-nav-btn"
t-att-disabled="!hasPrev"
t-on-click="prevQuestion">
← Prev
</button>
<button class="ec-nav-btn"
t-att-disabled="!hasNext"
t-on-click="nextQuestion">
Next →
</button>
<button t-att-class="'ec-flag-btn' + (isCurrentFlagged ? ' ec-flag-btn--active' : '')"
t-on-click="toggleFlag">
<span></span>
<span t-if="isCurrentFlagged">Flagged</span>
<span t-else="">Flag</span>
</button>
<div class="ec-dots">
<t t-foreach="questionDots" t-as="dot" t-key="dot.idx">
<div t-att-class="'ec-dot'
+ (dot.current ? ' ec-dot--current' : '')
+ (dot.answered ? ' ec-dot--answered' : '')
+ (dot.flagged ? ' ec-dot--flagged' : '')"
t-on-click="() => this.goToQuestion(dot.idx)">
<t t-esc="dot.idx + 1"/>
</div>
</t>
</div>
<div class="ec-bottombar-spacer"/>
<button class="ec-review-btn" t-on-click="showReviewPanel">
Review All
</button>
<t t-if="isLastSection">
<button class="ec-submit-btn" t-on-click="confirmSubmit">
Submit Exam
</button>
</t>
<t t-else="">
<button class="ec-submit-btn" t-on-click="nextSection">
Next Section →
</button>
</t>
</div>
<!-- ── Review Overlay ───────────────────────────────── -->
<t t-if="state.showReview">
<div class="ec-review-overlay" t-on-click.self="hideReviewPanel">
<div class="ec-review-panel">
<div class="ec-review-header">
<div class="ec-review-title">Review Your Answers</div>
<button class="ec-review-close"
t-on-click="hideReviewPanel"></button>
</div>
<!-- Stats -->
<div class="ec-review-stats">
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalAnswered"/>
<div class="ec-review-stat-label">Answered</div>
</div>
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalAllQuestions - totalAnswered"/>
<div class="ec-review-stat-label">Unanswered</div>
</div>
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalFlagged"/>
<div class="ec-review-stat-label">Flagged</div>
</div>
</div>
<!-- Legend -->
<div class="ec-review-legend">
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--answered"/>
<span>Answered</span>
</div>
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--flagged"/>
<span>Flagged</span>
</div>
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--unanswered"/>
<span>Unanswered</span>
</div>
</div>
<!-- Section grids -->
<t t-foreach="reviewSections" t-as="section" t-key="section.sIdx">
<div class="ec-review-section">
<div class="ec-review-section-title"
t-esc="section.name or section.skill or ('Section ' + (section.sIdx + 1))"/>
<div class="ec-review-grid">
<t t-foreach="section.questions" t-as="q" t-key="q.id">
<div t-att-class="'ec-review-cell'
+ (q.answered ? ' ec-review-cell--answered' : ' ec-review-cell--unanswered')
+ (q.flagged ? ' ec-review-cell--flagged' : '')"
t-on-click="() => this.reviewGoTo(q.sIdx, q.qIdx)">
<t t-esc="q.qIdx + 1"/>
</div>
</t>
</div>
</div>
</t>
<div class="ec-review-footer">
<button class="ec-modal-cancel"
t-on-click="hideReviewPanel">
Back to Exam
</button>
<button class="ec-modal-confirm"
t-on-click="confirmSubmit">
Submit Exam
</button>
</div>
</div>
</div>
</t>
<!-- ── Submit Confirmation Modal ────────────────────── -->
<t t-if="state.showConfirmSubmit">
<div class="ec-modal-overlay" t-on-click.self="cancelSubmit">
<div class="ec-modal">
<div class="ec-modal-icon">📝</div>
<div class="ec-modal-title">Submit Exam?</div>
<div class="ec-modal-body">
You have answered <b t-esc="totalAnswered"/> out of
<b t-esc="totalAllQuestions"/> questions.
<t t-if="totalAllQuestions - totalAnswered > 0">
<br/>
<span style="color: var(--ec-warning);">
<t t-esc="totalAllQuestions - totalAnswered"/>
question(s) are still unanswered.
</span>
</t>
<br/><br/>
Once submitted, you cannot change your answers.
</div>
<div class="ec-modal-actions">
<button class="ec-modal-cancel"
t-on-click="cancelSubmit">
Go Back
</button>
<button class="ec-modal-confirm"
t-on-click="submitExam">
Submit Now
</button>
</div>
</div>
</div>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
PlacementApp — CAT adaptive placement test
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.PlacementApp">
<div class="ec-exam-fullscreen" t-ref="root">
<!-- Loading -->
<t t-if="state.loading">
<div class="ec-loading">
<div class="ec-spinner"/>
<div class="ec-loading-text">Preparing your placement test...</div>
</div>
</t>
<!-- Error -->
<t t-elif="state.error and !state.currentQuestion">
<div class="ec-loading">
<div class="ec-loading-text" style="color: var(--ec-danger);"
t-esc="state.error"/>
</div>
</t>
<!-- Completed — Show results -->
<t t-elif="state.completed">
<div class="ec-topbar">
<div class="ec-topbar-title">Placement Test — Complete</div>
</div>
<div class="ec-placement-center">
<div class="ec-placement-card">
<div class="ec-placement-results">
<div class="ec-placement-results-icon"></div>
<div class="ec-placement-results-title">
Placement Complete!
</div>
<div class="ec-placement-results-level"
t-esc="cefrLabel"/>
<div class="ec-placement-results-desc">
Based on your responses, you have been placed at
the <b t-esc="cefrLabel"/> level.
Your personalized learning path is now ready.
</div>
<button class="ec-placement-results-btn"
t-on-click="goToDashboard">
Go to Dashboard
</button>
</div>
</div>
</div>
</t>
<!-- Active Question -->
<t t-else="">
<!-- Top bar with progress -->
<div class="ec-topbar">
<div class="ec-topbar-title">Placement Test</div>
<div class="ec-placement-progress">
<div class="ec-progress-bar-container">
<div class="ec-progress-bar-fill"
t-att-style="progressStyle"/>
</div>
</div>
<div class="ec-placement-info">
Question <t t-esc="state.questionsAnswered + 1"/>
</div>
</div>
<!-- Question card -->
<div class="ec-placement-center">
<div class="ec-placement-card" t-if="state.currentQuestion">
<div class="ec-placement-card-header">
<div class="ec-question-type"
t-esc="state.currentQuestion.question_type or 'question'"/>
<div class="ec-question-type"
t-esc="state.currentQuestion.skill or ''"/>
</div>
<div class="ec-placement-card-body">
<!-- Stem -->
<div class="ec-question-stem"
t-esc="state.currentQuestion.stem"/>
<!-- MCQ options (default for placement) -->
<t t-if="questionType === 'mcq' or questionType === 'multiple_choice'">
<div class="ec-option-list">
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- TFNG -->
<t t-elif="questionType === 'tfng'">
<div class="ec-tfng-options">
<div t-att-class="'ec-tfng-btn' + (state.answer === 'true' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('true')">True</div>
<div t-att-class="'ec-tfng-btn' + (state.answer === 'false' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('false')">False</div>
<div t-att-class="'ec-tfng-btn' + (state.answer === 'not_given' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('not_given')">Not Given</div>
</div>
</t>
<!-- Gap fill -->
<t t-elif="questionType === 'gap_fill'">
<input type="text"
class="ec-gap-input"
t-att-value="state.answer or ''"
t-on-input="(ev) => this.state.answer = ev.target.value"
placeholder="Type your answer..."/>
</t>
<!-- Fallback: text input -->
<t t-else="">
<div class="ec-option-list">
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- Error message -->
<t t-if="state.error">
<div style="color: var(--ec-danger); margin-top: 12px; font-size: 14px;"
t-esc="state.error"/>
</t>
</div>
<div class="ec-placement-actions">
<button class="ec-placement-next"
t-att-disabled="!hasAnswer or state.submitting"
t-on-click="submitAnswer">
<t t-if="state.submitting">Submitting...</t>
<t t-else="">Next Question →</t>
</button>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
ExamMCQ — Single-choice radio buttons
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamMCQ">
<div class="ec-option-list">
<t t-foreach="options" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (props.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamMCQMulti — Multi-choice checkboxes
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamMCQMulti">
<div class="ec-option-list">
<t t-foreach="options" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (this.isChecked(opt.key) ? ' ec-option--selected' : '')"
t-on-click="() => this.toggleOption(opt.key)">
<div class="ec-option-checkbox"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamGapFill — Inline text inputs
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamGapFill">
<div class="ec-gap-fill">
<t t-foreach="gaps" t-as="gap" t-key="gap_index">
<div class="ec-gap-row">
<span class="ec-gap-label" t-esc="(gap_index + 1) + '.'"/>
<input type="text"
class="ec-gap-input"
t-att-placeholder="gap.hint or 'Type your answer...'"
t-att-value="this.gapValue(gap_index)"
t-on-input="(ev) => this.onGapInput(gap_index, ev)"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamTFNG — True / False / Not Given selector
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamTFNG">
<div class="ec-tfng-options">
<t t-foreach="choices" t-as="choice" t-key="choice.key">
<div t-att-class="'ec-tfng-btn' + (props.answer === choice.key ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.select(choice.key)">
<t t-esc="choice.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamAudioRecorder — Record audio (speaking tasks)
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamAudioRecorder">
<div class="ec-audio-recorder">
<t t-if="state.error">
<div class="ec-audio-error" t-esc="state.error"/>
</t>
<t t-if="!state.recording and !state.recordedUrl">
<button class="ec-record-btn ec-record-btn--start"
t-on-click="startRecording">
🎙
</button>
<div class="ec-audio-hint">Click to start recording</div>
</t>
<t t-if="state.recording">
<div class="ec-audio-duration"
t-esc="formatDuration(state.duration)"/>
<button class="ec-record-btn ec-record-btn--stop"
t-on-click="stopRecording">
</button>
<div class="ec-audio-hint">Recording... Click to stop</div>
</t>
<t t-if="state.recordedUrl and !state.recording">
<div class="ec-audio-duration"
t-esc="formatDuration(state.duration)"/>
<audio class="ec-audio-playback"
controls=""
t-att-src="state.recordedUrl"/>
<button class="ec-record-btn ec-record-btn--start"
t-on-click="startRecording">
🎙
</button>
<div class="ec-audio-hint">Click to re-record</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamRichEditor — Writing task editor
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamRichEditor">
<div class="ec-rich-editor">
<textarea class="ec-editor-textarea"
t-ref="editor"
t-att-value="props.answer or ''"
t-on-input="onInput"
placeholder="Write your response here..."/>
<div class="ec-editor-footer">
<span t-att-class="'ec-word-count'
+ (state.wordCount &lt; minWords ? ' ec-word-count--low' : '')
+ (state.wordCount >= minWords and state.wordCount &lt;= maxWords ? ' ec-word-count--ok' : '')
+ (state.wordCount > maxWords ? ' ec-word-count--over' : '')">
<t t-esc="state.wordCount"/> words
</span>
<span class="ec-word-range">
Target: <t t-esc="minWords"/><t t-esc="maxWords"/> words
</span>
</div>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamReadingPassage — Scrollable passage display
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamReadingPassage">
<div class="ec-passage-panel">
<div class="ec-passage-title" t-esc="passageTitle"/>
<div class="ec-passage-body" t-esc="passageContent"/>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamNumericalInput — Number input with tolerance
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamNumericalInput">
<div>
<div class="ec-numerical-group">
<input type="number"
class="ec-numerical-input"
step="any"
t-att-value="props.answer or ''"
t-on-input="onInput"
placeholder="Enter value"/>
<span t-if="unit" class="ec-numerical-unit" t-esc="unit"/>
</div>
<div t-if="tolerance" class="ec-numerical-tolerance">
Accepted tolerance: ± <t t-esc="tolerance"/>
</div>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamCodeEditor — Code editing textarea
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamCodeEditor">
<div class="ec-code-editor">
<div class="ec-code-lang" t-esc="language"/>
<textarea class="ec-code-textarea"
t-ref="codeEditor"
t-att-value="props.answer or starterCode"
t-on-input="onInput"
placeholder="Write your code here..."
spellcheck="false"/>
</div>
</t>
</templates>