feat: initial backend codebase — EnCoach v3
Complete Odoo 19 backend with 25 custom addons: - encoach_core: user/entity/role management - encoach_api: REST API + JWT auth - encoach_ai: OpenAI integration, AI settings, generation - encoach_ai_course: AI-powered English & IELTS course generation - encoach_exam_template/session: exam creation, structures, sessions - encoach_scoring: AI auto-grading + manual approval - encoach_vector: pgvector RAG integration - encoach_adaptive: adaptive learning engine - encoach_placement: placement testing - encoach_taxonomy/resources: content taxonomy & resource management - Plus 14 more modules for courses, branding, portal, etc. Includes docs: user guide, generation report, developer workflow. Made-with: Cursor
This commit is contained in:
1
custom_addons/encoach_portal/__init__.py
Normal file
1
custom_addons/encoach_portal/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import controllers
|
||||
34
custom_addons/encoach_portal/__manifest__.py
Normal file
34
custom_addons/encoach_portal/__manifest__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
'name': 'EnCoach Student Portal',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education/Website',
|
||||
'summary': 'Student-facing portal pages with QWeb templates and OWL components',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'website',
|
||||
'portal',
|
||||
'encoach_core',
|
||||
'encoach_signup',
|
||||
'encoach_placement',
|
||||
'encoach_scoring',
|
||||
'encoach_course_gen',
|
||||
'encoach_exam_template',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/templates.xml',
|
||||
'views/portal_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'encoach_portal/static/src/css/portal.css',
|
||||
'encoach_portal/static/src/js/password_strength.js',
|
||||
'encoach_portal/static/src/js/onboarding_wizard.js',
|
||||
'encoach_portal/static/src/xml/onboarding_wizard.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
custom_addons/encoach_portal/controllers/__init__.py
Normal file
1
custom_addons/encoach_portal/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import portal
|
||||
225
custom_addons/encoach_portal/controllers/portal.py
Normal file
225
custom_addons/encoach_portal/controllers/portal.py
Normal file
@@ -0,0 +1,225 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if 'course_count' in counters:
|
||||
values['course_count'] = request.env['op.course'].sudo().search_count([])
|
||||
return values
|
||||
|
||||
@http.route('/register', type='http', auth='public', website=True)
|
||||
def register_page(self, **kw):
|
||||
captcha_key = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'encoach.captcha_site_key', '',
|
||||
)
|
||||
return request.render('encoach_portal.register', {
|
||||
'captcha_site_key': captcha_key,
|
||||
})
|
||||
|
||||
@http.route('/verify-email', type='http', auth='public', website=True)
|
||||
def verify_email_page(self, **kw):
|
||||
return request.render('encoach_portal.verify_email', {
|
||||
'email': kw.get('email', ''),
|
||||
})
|
||||
|
||||
@http.route('/onboarding', type='http', auth='user', website=True)
|
||||
def onboarding_page(self, **kw):
|
||||
user = request.env.user
|
||||
profile = request.env['encoach.student.profile'].sudo().search(
|
||||
[('user_id', '=', user.id)], limit=1,
|
||||
)
|
||||
return request.render('encoach_portal.onboarding', {
|
||||
'user': user,
|
||||
'profile': profile,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON-RPC: /encoach/onboarding/goals (called by OWL wizard)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/encoach/onboarding/goals', type='json', auth='user')
|
||||
def onboarding_goals_rpc(self, **kw):
|
||||
Template = request.env['encoach.exam.template'].sudo()
|
||||
templates = Template.search([('active', '=', True)])
|
||||
seen = set()
|
||||
goals = []
|
||||
icon_map = {
|
||||
'ielts': 'fa-language', 'general_english': 'fa-comments',
|
||||
'general english': 'fa-comments', 'academic': 'fa-graduation-cap',
|
||||
'math': 'fa-calculator', 'mathematics': 'fa-calculator',
|
||||
'it': 'fa-laptop', 'information technology': 'fa-laptop',
|
||||
}
|
||||
for tpl in templates:
|
||||
subj = tpl.subject_id
|
||||
if subj and subj.name and subj.name.lower() not in seen:
|
||||
seen.add(subj.name.lower())
|
||||
goal_id = subj.name.lower().replace(' ', '_')
|
||||
goals.append({
|
||||
'id': goal_id,
|
||||
'name': subj.name,
|
||||
'icon': icon_map.get(goal_id, icon_map.get(subj.name.lower(), 'fa-book')),
|
||||
})
|
||||
if not goals:
|
||||
goals = [
|
||||
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
|
||||
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
|
||||
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
|
||||
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
|
||||
]
|
||||
return {'goals': goals}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON-RPC: /encoach/onboarding/save (called by OWL wizard)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/encoach/onboarding/save', type='json', auth='user')
|
||||
def onboarding_save_rpc(self, **kw):
|
||||
user = request.env.user
|
||||
goal = kw.get('goal')
|
||||
target_level = kw.get('target_level')
|
||||
study_preference = kw.get('study_preference')
|
||||
skip_placement = kw.get('skip_placement', False)
|
||||
learning_style = kw.get('learning_style')
|
||||
hours_per_week = kw.get('hours_per_week', 0)
|
||||
|
||||
placement_decision = 'skip' if skip_placement else 'take_now'
|
||||
|
||||
profile_vals = {
|
||||
'user_id': user.id,
|
||||
'learning_goal': goal,
|
||||
'study_mode': study_preference,
|
||||
'hours_per_week': hours_per_week,
|
||||
'placement_decision': placement_decision,
|
||||
}
|
||||
|
||||
if learning_style:
|
||||
if isinstance(learning_style, list):
|
||||
profile_vals['learning_style'] = json.dumps(learning_style)
|
||||
elif isinstance(learning_style, str):
|
||||
profile_vals['learning_style'] = json.dumps([learning_style])
|
||||
|
||||
if skip_placement:
|
||||
profile_vals['cefr_level'] = 'b1'
|
||||
|
||||
Profile = request.env['encoach.student.profile'].sudo()
|
||||
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
||||
if profile:
|
||||
profile.write(profile_vals)
|
||||
else:
|
||||
profile = Profile.create(profile_vals)
|
||||
|
||||
user.sudo().write({
|
||||
'account_status': 'activated',
|
||||
'first_login': False,
|
||||
})
|
||||
return {'ok': True, 'profile_id': profile.id, 'placement_decision': placement_decision}
|
||||
|
||||
@http.route('/my/dashboard', type='http', auth='user', website=True)
|
||||
def student_dashboard(self, **kw):
|
||||
user = request.env.user
|
||||
|
||||
# Entity students must complete placement test first
|
||||
if getattr(user, 'account_source', '') == 'entity_bulk_upload':
|
||||
completed_session = request.env['encoach.cat.session'].sudo().search([
|
||||
('student_id', '=', user.id),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1)
|
||||
if not completed_session:
|
||||
return request.redirect('/my/placement')
|
||||
|
||||
profile = request.env['encoach.student.profile'].sudo().search(
|
||||
[('user_id', '=', user.id)], limit=1,
|
||||
)
|
||||
courses = request.env['op.course'].sudo().search([], limit=10)
|
||||
attempts = request.env['encoach.student.attempt'].sudo().search(
|
||||
[('student_id', '=', user.id)], limit=5, order='started_at desc',
|
||||
)
|
||||
return request.render('encoach_portal.student_dashboard', {
|
||||
'user': user,
|
||||
'profile': profile,
|
||||
'courses': courses,
|
||||
'attempts': attempts,
|
||||
})
|
||||
|
||||
@http.route('/my/placement', type='http', auth='user', website=True)
|
||||
def placement_page(self, **kw):
|
||||
user = request.env.user
|
||||
session = request.env['encoach.cat.session'].sudo().search(
|
||||
[('student_id', '=', user.id)], limit=1, order='id desc',
|
||||
)
|
||||
return request.render('encoach_portal.placement_briefing', {
|
||||
'session': session,
|
||||
'user': user,
|
||||
})
|
||||
|
||||
@http.route('/my/placement/results', type='http', auth='user', website=True)
|
||||
def placement_results_page(self, **kw):
|
||||
user = request.env.user
|
||||
session = request.env['encoach.cat.session'].sudo().search(
|
||||
[('student_id', '=', user.id), ('status', '=', 'completed')],
|
||||
limit=1, order='id desc',
|
||||
)
|
||||
abilities = request.env['encoach.student.ability.model'].sudo().search(
|
||||
[('student_id', '=', user.id)],
|
||||
)
|
||||
return request.render('encoach_portal.placement_results', {
|
||||
'session': session,
|
||||
'abilities': abilities,
|
||||
})
|
||||
|
||||
@http.route('/my/course/<int:course_id>', type='http', auth='user', website=True)
|
||||
def course_detail(self, course_id, **kw):
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return request.not_found()
|
||||
modules = request.env['encoach.course.module'].sudo().search(
|
||||
[('course_id', '=', course_id)], order='sequence',
|
||||
)
|
||||
return request.render('encoach_portal.course_detail', {
|
||||
'course': course,
|
||||
'modules': modules,
|
||||
})
|
||||
|
||||
@http.route('/my/exam/<int:attempt_id>/results', type='http', auth='user', website=True)
|
||||
def exam_results(self, attempt_id, **kw):
|
||||
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
|
||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||
return request.not_found()
|
||||
if attempt.status not in ('released', 'scored'):
|
||||
return request.render('encoach_portal.results_pending', {
|
||||
'attempt': attempt,
|
||||
})
|
||||
scores = request.env['encoach.score'].sudo().search(
|
||||
[('attempt_id', '=', attempt_id)],
|
||||
)
|
||||
feedback = request.env['encoach.feedback'].sudo().search(
|
||||
[('attempt_id', '=', attempt_id)],
|
||||
)
|
||||
return request.render('encoach_portal.exam_results', {
|
||||
'attempt': attempt,
|
||||
'scores': scores,
|
||||
'feedback': feedback,
|
||||
})
|
||||
|
||||
@http.route('/my/profile', type='http', auth='user', website=True)
|
||||
def profile_page(self, **kw):
|
||||
user = request.env.user
|
||||
profile = request.env['encoach.student.profile'].sudo().search(
|
||||
[('user_id', '=', user.id)], limit=1,
|
||||
)
|
||||
return request.render('encoach_portal.profile', {
|
||||
'user': user,
|
||||
'profile': profile,
|
||||
})
|
||||
|
||||
@http.route('/verify/<string:hash_code>', type='http', auth='public', website=True)
|
||||
def verification_page(self, hash_code, **kw):
|
||||
return request.render('encoach_portal.score_verification', {
|
||||
'hash_code': hash_code,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
139
custom_addons/encoach_portal/static/src/css/portal.css
Normal file
139
custom_addons/encoach_portal/static/src/css/portal.css
Normal file
@@ -0,0 +1,139 @@
|
||||
/* ============================================================
|
||||
EnCoach Portal – Custom Styles
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--ec-primary: #4361ee;
|
||||
--ec-primary-soft: rgba(67, 97, 238, .08);
|
||||
--ec-success: #06d6a0;
|
||||
--ec-warning: #ffd166;
|
||||
--ec-radius: 1rem;
|
||||
}
|
||||
|
||||
/* ---- General ---- */
|
||||
|
||||
.ec-register-section,
|
||||
.ec-verify-section,
|
||||
.ec-onboarding-section,
|
||||
.ec-dashboard-section,
|
||||
.ec-placement-section,
|
||||
.ec-results-section,
|
||||
.ec-course-section,
|
||||
.ec-exam-results-section,
|
||||
.ec-pending-section,
|
||||
.ec-profile-section,
|
||||
.ec-verification-section {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
/* ---- OTP Boxes ---- */
|
||||
|
||||
.ec-otp-box {
|
||||
width: 48px;
|
||||
height: 56px;
|
||||
font-size: 1.5rem;
|
||||
caret-color: var(--ec-primary);
|
||||
}
|
||||
|
||||
.ec-otp-box:focus {
|
||||
border-color: var(--ec-primary);
|
||||
box-shadow: 0 0 0 .2rem var(--ec-primary-soft);
|
||||
}
|
||||
|
||||
/* ---- Action Cards (dashboard) ---- */
|
||||
|
||||
.ec-action-card {
|
||||
transition: transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.ec-action-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .08) !important;
|
||||
}
|
||||
|
||||
/* ---- Course Cards ---- */
|
||||
|
||||
.ec-course-card {
|
||||
transition: transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.ec-course-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 .5rem 1.2rem rgba(0, 0, 0, .07) !important;
|
||||
}
|
||||
|
||||
/* ---- Module List (locked state) ---- */
|
||||
|
||||
.ec-module-locked {
|
||||
opacity: .55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Wizard ---- */
|
||||
|
||||
.ec-wizard {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ec-wizard-step.active small {
|
||||
color: var(--ec-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ec-wizard-step.completed small {
|
||||
color: var(--ec-success);
|
||||
}
|
||||
|
||||
.ec-selectable-card {
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .1s ease;
|
||||
}
|
||||
|
||||
.ec-selectable-card:hover {
|
||||
border-color: var(--ec-primary) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ---- Score Highlights ---- */
|
||||
|
||||
.ec-score-highlight {
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
|
||||
.ec-score-highlight:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* ---- Password Strength ---- */
|
||||
|
||||
.ec-password-strength .progress-bar {
|
||||
transition: width .3s ease, background-color .3s ease;
|
||||
}
|
||||
|
||||
/* ---- Chart Container ---- */
|
||||
|
||||
.ec-chart-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---- CEFR Badge ---- */
|
||||
|
||||
.ec-cefr-badge {
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
/* ---- Responsive Tweaks ---- */
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.ec-otp-box {
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ec-wizard-step small {
|
||||
font-size: .65rem;
|
||||
}
|
||||
}
|
||||
318
custom_addons/encoach_portal/static/src/js/onboarding_wizard.js
Normal file
318
custom_addons/encoach_portal/static/src/js/onboarding_wizard.js
Normal file
@@ -0,0 +1,318 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, useRef, onMounted, xml } from "@odoo/owl";
|
||||
|
||||
async function rpc(url, params) {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", id: 1, params: params || {} }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) throw new Error(data.error.data?.message || "RPC error");
|
||||
return data.result;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
|
||||
{ key: "level", title: "Target Level", icon: "fa-signal" },
|
||||
{ key: "prefs", title: "Study Preferences", icon: "fa-sliders" },
|
||||
{ key: "placement", title: "Placement Test", icon: "fa-tasks" },
|
||||
];
|
||||
|
||||
const FALLBACK_GOALS = [
|
||||
{ id: "ielts", label: "IELTS Preparation", icon: "fa-language", desc: "Prepare for your IELTS exam" },
|
||||
{ id: "general", label: "General English", icon: "fa-comments", desc: "Improve everyday English" },
|
||||
{ id: "academic", label: "Academic English", icon: "fa-graduation-cap", desc: "University-level English" },
|
||||
{ id: "math", label: "Mathematics", icon: "fa-calculator", desc: "Strengthen maths skills" },
|
||||
{ id: "it", label: "IT Skills", icon: "fa-laptop", desc: "Computer and IT proficiency" },
|
||||
];
|
||||
|
||||
const ICON_MAP = {
|
||||
"ielts": "fa-language",
|
||||
"general_english": "fa-comments",
|
||||
"general": "fa-comments",
|
||||
"academic": "fa-graduation-cap",
|
||||
"math": "fa-calculator",
|
||||
"mathematics": "fa-calculator",
|
||||
"it": "fa-laptop",
|
||||
"information_technology": "fa-laptop",
|
||||
};
|
||||
|
||||
const LEVELS = [
|
||||
{ id: "A1", label: "A1 \u2013 Beginner" },
|
||||
{ id: "A2", label: "A2 \u2013 Elementary" },
|
||||
{ id: "B1", label: "B1 \u2013 Intermediate" },
|
||||
{ id: "B2", label: "B2 \u2013 Upper Intermediate" },
|
||||
{ id: "C1", label: "C1 \u2013 Advanced" },
|
||||
{ id: "C2", label: "C2 \u2013 Proficiency" },
|
||||
{ id: "band_5", label: "IELTS Band 5.0" },
|
||||
{ id: "band_6", label: "IELTS Band 6.0" },
|
||||
{ id: "band_7", label: "IELTS Band 7.0+" },
|
||||
];
|
||||
|
||||
const STUDY_PREFS = [
|
||||
{ id: "self_paced", label: "Self-Paced", icon: "fa-user", desc: "Learn at your own speed" },
|
||||
{ id: "structured", label: "Structured", icon: "fa-calendar", desc: "Follow a weekly schedule" },
|
||||
{ id: "intensive", label: "Intensive", icon: "fa-bolt", desc: "Fast-track learning" },
|
||||
];
|
||||
|
||||
export class OnboardingWizard extends Component {
|
||||
static template = xml`
|
||||
<div class="ec-wizard" t-ref="root">
|
||||
<div class="ec-wizard-progress mb-4">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<t t-foreach="steps" t-as="step" t-key="step.key">
|
||||
<div t-attf-class="ec-wizard-step text-center flex-fill #{ step_index <= state.currentStep ? 'active' : '' } #{ step_index < state.currentStep ? 'completed' : '' }">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{ step_index <= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:36px;height:36px;">
|
||||
<i t-attf-class="fa #{ step_index < state.currentStep ? 'fa-check' : step.icon }"/>
|
||||
</span>
|
||||
<small class="d-block" t-esc="step.title"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="progress rounded-pill" style="height:6px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{ progressPercent }%;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
|
||||
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="goals" t-as="goal" t-key="goal.id">
|
||||
<div class="col-md-6">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickGoal" t-att-data-id="goal.id" role="button">
|
||||
<div class="card-body p-3 d-flex align-items-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{ state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:44px;height:44px;">
|
||||
<i t-attf-class="fa #{ goal.icon }"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
|
||||
<small class="text-muted" t-esc="goal.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
|
||||
<p class="text-muted text-center mb-4">Select your target proficiency</p>
|
||||
<div class="row g-2 justify-content-center">
|
||||
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{ state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickLevel" t-att-data-id="lvl.id" role="button">
|
||||
<div class="card-body p-3 text-center">
|
||||
<span class="fw-semibold" t-esc="lvl.label"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
|
||||
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
|
||||
<div class="row g-3 justify-content-center">
|
||||
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
|
||||
<div class="col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickPref" t-att-data-id="pref.id" role="button">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{ state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:52px;height:52px;">
|
||||
<i t-attf-class="fa fa-lg #{ pref.icon }"/>
|
||||
</span>
|
||||
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
|
||||
<small class="text-muted" t-esc="pref.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Goal</small>
|
||||
<span class="fw-bold" t-esc="state.selectedGoal"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Target Level</small>
|
||||
<span class="fw-bold" t-esc="state.selectedLevel"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Study Style</small>
|
||||
<span class="fw-bold" t-esc="state.selectedPref"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||
<i class="fa fa-tasks fa-lg"/>
|
||||
</span>
|
||||
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||
<p class="text-muted mb-0">20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{ isFirstStep ? 'invisible' : '' }"
|
||||
t-on-click="prevStep">
|
||||
<i class="fa fa-arrow-left me-2"/>Back
|
||||
</button>
|
||||
<t t-if="!isLastStep">
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="!canProceed"
|
||||
t-on-click="nextStep">
|
||||
Next<i class="fa fa-arrow-right ms-2"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="skipPlacement">
|
||||
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/></t>
|
||||
<t t-else="">Skip for now</t>
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="takePlacement">
|
||||
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/>Saving\u2026</t>
|
||||
<t t-else=""><i class="fa fa-play me-2"/>Take Placement Test</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.steps = STEPS;
|
||||
this.goals = FALLBACK_GOALS;
|
||||
this.levels = LEVELS;
|
||||
this.studyPrefs = STUDY_PREFS;
|
||||
|
||||
this.state = useState({
|
||||
currentStep: 0,
|
||||
selectedGoal: null,
|
||||
selectedLevel: null,
|
||||
selectedPref: null,
|
||||
saving: false,
|
||||
error: null,
|
||||
goalsLoaded: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const el = document.getElementById("ec_onboarding_wizard");
|
||||
if (el) {
|
||||
this.userId = el.dataset.userId;
|
||||
this.profileId = el.dataset.profileId;
|
||||
}
|
||||
await this._loadGoals();
|
||||
});
|
||||
}
|
||||
|
||||
async _loadGoals() {
|
||||
try {
|
||||
const resp = await rpc("/encoach/onboarding/goals");
|
||||
if (resp && resp.goals && resp.goals.length) {
|
||||
this.goals = resp.goals.map((g) => ({
|
||||
id: g.id,
|
||||
label: g.name || g.label || g.id,
|
||||
icon: g.icon || ICON_MAP[g.id] || "fa-book",
|
||||
desc: g.desc || g.description || "",
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to hardcoded goals on error
|
||||
}
|
||||
this.state.goalsLoaded = true;
|
||||
}
|
||||
|
||||
get isFirstStep() { return this.state.currentStep === 0; }
|
||||
get isLastStep() { return this.state.currentStep === STEPS.length - 1; }
|
||||
|
||||
get canProceed() {
|
||||
switch (this.state.currentStep) {
|
||||
case 0: return !!this.state.selectedGoal;
|
||||
case 1: return !!this.state.selectedLevel;
|
||||
case 2: return !!this.state.selectedPref;
|
||||
case 3: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
get progressPercent() {
|
||||
return Math.round(((this.state.currentStep + 1) / STEPS.length) * 100);
|
||||
}
|
||||
|
||||
onClickGoal(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedGoal = el.dataset.id;
|
||||
}
|
||||
onClickLevel(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedLevel = el.dataset.id;
|
||||
}
|
||||
onClickPref(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedPref = el.dataset.id;
|
||||
}
|
||||
|
||||
nextStep() { if (this.canProceed && !this.isLastStep) this.state.currentStep++; }
|
||||
prevStep() { if (!this.isFirstStep) this.state.currentStep--; }
|
||||
|
||||
async submit(skipPlacement) {
|
||||
this.state.saving = true;
|
||||
this.state.error = null;
|
||||
try {
|
||||
await rpc("/encoach/onboarding/save", {
|
||||
goal: this.state.selectedGoal,
|
||||
target_level: this.state.selectedLevel,
|
||||
study_preference: this.state.selectedPref,
|
||||
skip_placement: !!skipPlacement,
|
||||
});
|
||||
window.location.href = skipPlacement ? "/my/dashboard" : "/my/placement";
|
||||
} catch (e) {
|
||||
this.state.error = "Something went wrong. Please try again.";
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
takePlacement() { this.submit(false); }
|
||||
skipPlacement() { this.submit(true); }
|
||||
}
|
||||
|
||||
function mountOnboardingWizard() {
|
||||
const target = document.getElementById("ec_onboarding_wizard");
|
||||
if (!target) return;
|
||||
const { mount } = owl;
|
||||
mount(OnboardingWizard, target);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountOnboardingWizard);
|
||||
} else {
|
||||
mountOnboardingWizard();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function computeStrength(password) {
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ label: "", width: "0%", color: "" },
|
||||
{ label: "Weak", width: "25%", color: "bg-danger" },
|
||||
{ label: "Fair", width: "50%", color: "bg-warning" },
|
||||
{ label: "Good", width: "75%", color: "bg-info" },
|
||||
{ label: "Strong", width: "100%", color: "bg-success" },
|
||||
];
|
||||
|
||||
function initPasswordStrength() {
|
||||
const input = document.getElementById("reg_password");
|
||||
const bar = document.getElementById("password_strength_bar");
|
||||
const text = document.getElementById("password_strength_text");
|
||||
if (!input || !bar || !text) return;
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
const score = computeStrength(this.value);
|
||||
const level = LEVELS[score];
|
||||
bar.style.width = level.width;
|
||||
bar.className = "progress-bar " + level.color;
|
||||
text.textContent = level.label;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initPasswordStrength);
|
||||
} else {
|
||||
initPasswordStrength();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="encoach_portal.OnboardingWizard">
|
||||
<div class="ec-wizard" t-ref="root">
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="ec-wizard-progress mb-4">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<t t-foreach="steps" t-as="step" t-key="step.key">
|
||||
<div t-attf-class="ec-wizard-step text-center flex-fill #{step_index <= state.currentStep ? 'active' : ''} #{step_index < state.currentStep ? 'completed' : ''}">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{step_index <= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:36px;height:36px;">
|
||||
<i t-attf-class="fa #{step_index < state.currentStep ? 'fa-check' : step.icon}"/>
|
||||
</span>
|
||||
<small class="d-block" t-esc="step.title"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="progress rounded-pill" style="height:6px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{progressPercent}%;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Learning Goal -->
|
||||
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
|
||||
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="goals" t-as="goal" t-key="goal.id">
|
||||
<div class="col-md-6">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light'}"
|
||||
t-on-click="() => this.selectGoal(goal.id)" role="button">
|
||||
<div class="card-body p-3 d-flex align-items-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:44px;height:44px;">
|
||||
<i t-attf-class="fa #{goal.icon}"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
|
||||
<small class="text-muted" t-esc="goal.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Target Level -->
|
||||
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
|
||||
<p class="text-muted text-center mb-4">Select your target proficiency</p>
|
||||
<div class="row g-2 justify-content-center">
|
||||
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light'}"
|
||||
t-on-click="() => this.selectLevel(lvl.id)" role="button">
|
||||
<div class="card-body p-3 text-center">
|
||||
<span class="fw-semibold" t-esc="lvl.label"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Study Preferences -->
|
||||
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
|
||||
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
|
||||
<div class="row g-3 justify-content-center">
|
||||
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
|
||||
<div class="col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light'}"
|
||||
t-on-click="() => this.selectPref(pref.id)" role="button">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:52px;height:52px;">
|
||||
<i t-attf-class="fa fa-lg #{pref.icon}"/>
|
||||
</span>
|
||||
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
|
||||
<small class="text-muted" t-esc="pref.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Placement Test -->
|
||||
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||
|
||||
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Goal</small>
|
||||
<span class="fw-bold" t-esc="state.selectedGoal"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Target Level</small>
|
||||
<span class="fw-bold" t-esc="state.selectedLevel"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Study Style</small>
|
||||
<span class="fw-bold" t-esc="state.selectedPref"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||
<i class="fa fa-tasks fa-lg"/>
|
||||
</span>
|
||||
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||
<p class="text-muted mb-0">20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{isFirstStep ? 'invisible' : ''}"
|
||||
t-on-click="prevStep">
|
||||
<i class="fa fa-arrow-left me-2"/>Back
|
||||
</button>
|
||||
<t t-if="!isLastStep">
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="!canProceed"
|
||||
t-on-click="nextStep">
|
||||
Next<i class="fa fa-arrow-right ms-2"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="skipPlacement">
|
||||
<t t-if="state.saving">
|
||||
<span class="spinner-border spinner-border-sm me-2"/>
|
||||
</t>
|
||||
<t t-else="">Skip for now</t>
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="takePlacement">
|
||||
<t t-if="state.saving">
|
||||
<span class="spinner-border spinner-border-sm me-2"/>Saving…
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play me-2"/>Take Placement Test
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
33
custom_addons/encoach_portal/views/portal_menus.xml
Normal file
33
custom_addons/encoach_portal/views/portal_menus.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Top-level website menu for student portal -->
|
||||
<record id="menu_encoach_portal_root" model="website.menu">
|
||||
<field name="name">My Learning</field>
|
||||
<field name="url">/my/dashboard</field>
|
||||
<field name="parent_id" ref="website.main_menu"/>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record id="menu_encoach_dashboard" model="website.menu">
|
||||
<field name="name">Dashboard</field>
|
||||
<field name="url">/my/dashboard</field>
|
||||
<field name="parent_id" ref="menu_encoach_portal_root"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record id="menu_encoach_placement" model="website.menu">
|
||||
<field name="name">Placement Test</field>
|
||||
<field name="url">/my/placement</field>
|
||||
<field name="parent_id" ref="menu_encoach_portal_root"/>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="menu_encoach_profile" model="website.menu">
|
||||
<field name="name">My Profile</field>
|
||||
<field name="url">/my/profile</field>
|
||||
<field name="parent_id" ref="menu_encoach_portal_root"/>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
858
custom_addons/encoach_portal/views/templates.xml
Normal file
858
custom_addons/encoach_portal/views/templates.xml
Normal file
@@ -0,0 +1,858 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ===================================================================
|
||||
REGISTER PAGE
|
||||
=================================================================== -->
|
||||
<template id="register" name="EnCoach Register">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-register-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-5 col-md-7">
|
||||
<div class="card shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="fw-bold text-primary mb-1">Create Account</h2>
|
||||
<p class="text-muted">Start your learning journey with EnCoach</p>
|
||||
</div>
|
||||
<form action="/register" method="post" class="ec-register-form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="reg_name" class="form-label fw-semibold">Full Name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="fa fa-user text-muted"/></span>
|
||||
<input type="text" id="reg_name" name="name" class="form-control border-start-0" placeholder="Enter your full name" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="reg_email" class="form-label fw-semibold">Email Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="fa fa-envelope text-muted"/></span>
|
||||
<input type="email" id="reg_email" name="email" class="form-control border-start-0" placeholder="you@example.com" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="reg_password" class="form-label fw-semibold">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="fa fa-lock text-muted"/></span>
|
||||
<input type="password" id="reg_password" name="password" class="form-control border-start-0" placeholder="Min. 8 characters" required="required" minlength="8"/>
|
||||
</div>
|
||||
<div class="ec-password-strength mt-2">
|
||||
<div class="progress" style="height: 4px;">
|
||||
<div id="password_strength_bar" class="progress-bar" role="progressbar" style="width: 0%;"/>
|
||||
</div>
|
||||
<small id="password_strength_text" class="text-muted"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="reg_confirm" class="form-label fw-semibold">Confirm Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="fa fa-lock text-muted"/></span>
|
||||
<input type="password" id="reg_confirm" name="confirm_password" class="form-control border-start-0" placeholder="Re-enter password" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="captcha_site_key">
|
||||
<div class="mb-4 d-flex justify-content-center">
|
||||
<div class="ec-captcha-placeholder border rounded-3 p-3 text-center bg-light w-100">
|
||||
<small class="text-muted"><i class="fa fa-shield me-1"/>CAPTCHA verification</small>
|
||||
<div t-att-data-sitekey="captcha_site_key" class="g-recaptcha mt-2" data-callback="onCaptchaSuccess"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold rounded-3">
|
||||
<i class="fa fa-user-plus me-2"/>Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="text-muted">Already have an account?</span>
|
||||
<a href="/web/login" class="fw-semibold text-decoration-none ms-1">Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
VERIFY EMAIL PAGE
|
||||
=================================================================== -->
|
||||
<template id="verify_email" name="EnCoach Verify Email">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-verify-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-5 col-md-7">
|
||||
<div class="card shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4 p-md-5 text-center">
|
||||
<div class="ec-verify-icon mb-4">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10" style="width:72px;height:72px;">
|
||||
<i class="fa fa-envelope-open fa-2x text-primary"/>
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="fw-bold mb-2">Check Your Email</h2>
|
||||
<p class="text-muted mb-1">We sent a verification code to</p>
|
||||
<p class="fw-semibold mb-4" t-esc="email"/>
|
||||
|
||||
<form action="/verify-email" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="email" t-att-value="email"/>
|
||||
|
||||
<div class="ec-otp-inputs d-flex justify-content-center gap-2 mb-4">
|
||||
<input type="text" name="otp_1" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
<input type="text" name="otp_2" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
<input type="text" name="otp_3" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
<input type="text" name="otp_4" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
<input type="text" name="otp_5" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
<input type="text" name="otp_6" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold rounded-3 mb-3">
|
||||
Verify Email
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-muted mb-0">
|
||||
Didn't receive the code?
|
||||
<a href="#" class="fw-semibold text-decoration-none ec-resend-btn">Resend</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
ONBOARDING PAGE (OWL Wizard mount point)
|
||||
=================================================================== -->
|
||||
<template id="onboarding" name="EnCoach Onboarding">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-onboarding-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="fw-bold">Welcome, <t t-esc="user.name"/>!</h2>
|
||||
<p class="text-muted">Let's personalise your learning experience</p>
|
||||
</div>
|
||||
|
||||
<!-- OWL wizard mounts here -->
|
||||
<div id="ec_onboarding_wizard"
|
||||
t-att-data-user-id="user.id"
|
||||
t-att-data-profile-id="profile.id if profile else ''"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
STUDENT DASHBOARD
|
||||
=================================================================== -->
|
||||
<template id="student_dashboard" name="EnCoach Student Dashboard">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-dashboard-section py-5">
|
||||
<div class="container">
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<div class="row align-items-center mb-5">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold mb-1">Welcome back, <t t-esc="user.name"/>!</h2>
|
||||
<p class="text-muted mb-0">
|
||||
<t t-if="profile and profile.cefr_level">
|
||||
Your current level:
|
||||
<span class="badge rounded-pill bg-primary fs-6 ms-1" t-esc="profile.cefr_level"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-warning"><i class="fa fa-exclamation-circle me-1"/>Take a placement test to find your level</span>
|
||||
</t>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/my/placement" class="btn btn-outline-primary rounded-3">
|
||||
<i class="fa fa-compass me-1"/>Placement Test
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-4">
|
||||
<a href="/my/placement" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 me-3" style="width:48px;height:48px;">
|
||||
<i class="fa fa-play text-primary"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0">Start Placement</h6>
|
||||
<small class="text-muted">Find your level</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="#courses_section" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-success bg-opacity-10 me-3" style="width:48px;height:48px;">
|
||||
<i class="fa fa-book text-success"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0">Resume Course</h6>
|
||||
<small class="text-muted">Continue learning</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="#results_section" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-info bg-opacity-10 me-3" style="width:48px;height:48px;">
|
||||
<i class="fa fa-bar-chart text-info"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0">View Results</h6>
|
||||
<small class="text-muted">Track progress</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Courses Grid -->
|
||||
<div id="courses_section" class="mb-5">
|
||||
<h4 class="fw-bold mb-3"><i class="fa fa-graduation-cap me-2 text-primary"/>My Courses</h4>
|
||||
<t t-if="courses">
|
||||
<div class="row g-4">
|
||||
<t t-foreach="courses" t-as="course">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card border-0 shadow-sm rounded-4 h-100 ec-course-card">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-2" t-esc="course.name"/>
|
||||
<p class="text-muted small mb-3" t-esc="course.code or 'No description'"/>
|
||||
<div class="progress rounded-pill mb-2" style="height: 8px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">0% complete</small>
|
||||
<a t-attf-href="/my/course/#{course.id}" class="btn btn-sm btn-outline-primary rounded-3">Continue</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="card border-0 bg-light rounded-4 p-4 text-center">
|
||||
<p class="text-muted mb-0"><i class="fa fa-info-circle me-1"/>No courses enrolled yet. Complete your placement test to get started.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Recent Results -->
|
||||
<div id="results_section">
|
||||
<h4 class="fw-bold mb-3"><i class="fa fa-trophy me-2 text-warning"/>Recent Exam Results</h4>
|
||||
<t t-if="attempts">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle bg-white rounded-4 overflow-hidden shadow-sm">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Exam</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="attempts" t-as="a">
|
||||
<tr>
|
||||
<td class="fw-semibold" t-esc="a.display_name"/>
|
||||
<td class="text-muted" t-esc="a.started_at" t-options='{"widget": "date"}'/>
|
||||
<td>
|
||||
<span t-attf-class="badge rounded-pill #{('bg-success' if a.status in ('released','scored') else 'bg-secondary')}" t-esc="a.status"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a t-attf-href="/my/exam/#{a.id}/results" class="btn btn-sm btn-outline-primary rounded-3">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="card border-0 bg-light rounded-4 p-4 text-center">
|
||||
<p class="text-muted mb-0"><i class="fa fa-info-circle me-1"/>No exam results yet.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
PLACEMENT BRIEFING
|
||||
=================================================================== -->
|
||||
<template id="placement_briefing" name="EnCoach Placement Briefing">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-placement-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-md-9">
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="text-center mb-4">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 mb-3" style="width:80px;height:80px;">
|
||||
<i class="fa fa-flask fa-2x text-primary"/>
|
||||
</span>
|
||||
<h2 class="fw-bold">Placement Test</h2>
|
||||
<p class="text-muted">Discover your current proficiency level</p>
|
||||
</div>
|
||||
|
||||
<div class="ec-test-overview mb-4">
|
||||
<h5 class="fw-bold mb-3">What this test covers</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="fa fa-check-circle text-success mt-1 me-2"/>
|
||||
<span>Reading Comprehension</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="fa fa-check-circle text-success mt-1 me-2"/>
|
||||
<span>Listening Skills</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="fa fa-check-circle text-success mt-1 me-2"/>
|
||||
<span>Grammar & Vocabulary</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="fa fa-check-circle text-success mt-1 me-2"/>
|
||||
<span>Problem Solving</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-test-info bg-light rounded-3 p-3 mb-4">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="fw-bold text-primary fs-5">~25</div>
|
||||
<small class="text-muted">Minutes</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fw-bold text-primary fs-5">Adaptive</div>
|
||||
<small class="text-muted">Difficulty</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fw-bold text-primary fs-5">CEFR</div>
|
||||
<small class="text-muted">A1 – C2</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="fw-bold mb-3">Before you begin</h5>
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="d-flex align-items-start mb-2">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">1</span>
|
||||
<span>Find a quiet place with a stable internet connection</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-2">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">2</span>
|
||||
<span>The test adapts to your answers — do your best on every question</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-2">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">3</span>
|
||||
<span>You cannot pause or restart once you begin</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">4</span>
|
||||
<span>Results are available immediately upon completion</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/my/placement/start" class="btn btn-primary btn-lg px-5 py-2 rounded-3 fw-semibold">
|
||||
<i class="fa fa-play me-2"/>Begin Test
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
PLACEMENT RESULTS
|
||||
=================================================================== -->
|
||||
<template id="placement_results" name="EnCoach Placement Results">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-results-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
|
||||
<!-- CEFR Level Badge -->
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold mb-3">Your Placement Results</h2>
|
||||
<t t-if="session">
|
||||
<div class="ec-cefr-badge d-inline-flex align-items-center justify-content-center rounded-circle bg-primary text-white shadow" style="width:120px;height:120px;">
|
||||
<span class="fs-1 fw-bold" t-esc="session.final_cefr or '—'"/>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">CEFR Level</p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-warning rounded-3">No completed placement session found.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Skill Radar Chart (OWL mount point) -->
|
||||
<t t-if="abilities">
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Skill Overview</h5>
|
||||
<div id="ec_skill_radar_chart" class="ec-chart-container" style="height:300px;"
|
||||
t-att-data-abilities="json.dumps([{'skill': a.skill_id.name, 'theta': a.theta} for a in abilities])"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Skill Breakdown -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Skill Breakdown</h5>
|
||||
<t t-foreach="abilities" t-as="ability">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3" style="min-width:120px;">
|
||||
<span class="fw-semibold" t-esc="ability.skill_id.name or 'Skill'"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="progress rounded-pill" style="height:10px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" role="progressbar"
|
||||
t-attf-style="width: #{min(max(int((ability.theta + 3) / 6 * 100), 0), 100)}%;"
|
||||
t-att-aria-valuenow="ability.theta"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-3 text-end" style="min-width:60px;">
|
||||
<span class="fw-semibold" t-esc="'%.2f' % ability.theta"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Recommended Path -->
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-4 text-center">
|
||||
<h5 class="fw-bold mb-2">Recommended Learning Path</h5>
|
||||
<p class="text-muted mb-3">Based on your results, we've tailored a course path for you.</p>
|
||||
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
|
||||
<i class="fa fa-arrow-right me-2"/>Go to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
COURSE DETAIL
|
||||
=================================================================== -->
|
||||
<template id="course_detail" name="EnCoach Course Detail">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-course-section py-5">
|
||||
<div class="container">
|
||||
|
||||
<!-- Course Header -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h2 class="fw-bold mb-2" t-esc="course.name"/>
|
||||
<p class="text-muted mb-3" t-esc="course.code or 'No description available.'"/>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<div class="ec-progress-ring text-center">
|
||||
<div class="fs-2 fw-bold text-primary">0%</div>
|
||||
<small class="text-muted">Complete</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress rounded-pill mt-3" style="height:10px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" role="progressbar" style="width:0%;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Module List -->
|
||||
<h4 class="fw-bold mb-3"><i class="fa fa-list-ul me-2 text-primary"/>Modules</h4>
|
||||
<t t-if="modules">
|
||||
<div class="ec-module-list">
|
||||
<t t-foreach="modules" t-as="mod">
|
||||
<div t-attf-class="card border-0 shadow-sm rounded-4 mb-3 #{('ec-module-locked' if mod.status == 'locked' else '')}">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<t t-if="mod.status == 'completed'">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-success text-white" style="width:40px;height:40px;">
|
||||
<i class="fa fa-check"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="mod.status == 'locked'">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-secondary bg-opacity-25 text-secondary" style="width:40px;height:40px;">
|
||||
<i class="fa fa-lock"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary" style="width:40px;height:40px;">
|
||||
<i class="fa fa-play"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="fw-bold mb-1" t-esc="mod.name"/>
|
||||
<small class="text-muted" t-esc="mod.cefr_target or ''"/>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<t t-if="mod.status != 'locked'">
|
||||
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary" t-esc="mod.status or 'available'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="card border-0 bg-light rounded-4 p-4 text-center">
|
||||
<p class="text-muted mb-0">No modules available yet for this course.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
EXAM RESULTS
|
||||
=================================================================== -->
|
||||
<template id="exam_results" name="EnCoach Exam Results">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-exam-results-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
|
||||
<!-- Score Summary Card -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4 p-md-5 text-center">
|
||||
<h2 class="fw-bold mb-3">Exam Results</h2>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="ec-score-highlight bg-primary bg-opacity-10 rounded-4 p-3">
|
||||
<div class="fs-2 fw-bold text-primary" t-esc="'%.1f' % attempt.overall_band if attempt.overall_band else '—'"/>
|
||||
<small class="text-muted d-block">Overall Band</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="ec-score-highlight bg-success bg-opacity-10 rounded-4 p-3">
|
||||
<div class="fs-2 fw-bold text-success" t-esc="attempt.cefr_level or '—'"/>
|
||||
<small class="text-muted d-block">CEFR Level</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="ec-score-highlight bg-info bg-opacity-10 rounded-4 p-3">
|
||||
<div class="fs-2 fw-bold text-info" t-esc="attempt.status or '—'"/>
|
||||
<small class="text-muted d-block">Status</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skill Scores -->
|
||||
<t t-if="scores">
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Skill Scores</h5>
|
||||
<t t-foreach="scores" t-as="score">
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="fw-semibold" t-esc="score.skill_name or score.display_name"/>
|
||||
<span class="fw-bold text-primary" t-esc="'%.1f' % score.value if score.value else '—'"/>
|
||||
</div>
|
||||
<div class="progress rounded-pill" style="height:10px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" role="progressbar"
|
||||
t-attf-style="width: #{min(int(score.value / 9.0 * 100), 100) if score.value else 0}%;"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Feedback -->
|
||||
<t t-if="feedback">
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3"><i class="fa fa-comments me-2 text-warning"/>Feedback</h5>
|
||||
<t t-foreach="feedback" t-as="fb">
|
||||
<div class="ec-feedback-item bg-light rounded-3 p-3 mb-2">
|
||||
<strong t-esc="fb.skill_name or 'General'"/>
|
||||
<p class="mb-0 mt-1 text-muted" t-esc="fb.text or fb.comment or ''"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="d-flex flex-wrap gap-3 justify-content-center">
|
||||
<a t-attf-href="/my/exam/#{attempt.id}/pdf" class="btn btn-outline-primary rounded-3 px-4">
|
||||
<i class="fa fa-download me-2"/>Download PDF
|
||||
</a>
|
||||
<t t-if="attempt.verification_hash">
|
||||
<a t-attf-href="/verify/#{attempt.verification_hash}" class="btn btn-outline-secondary rounded-3 px-4" target="_blank">
|
||||
<i class="fa fa-qrcode me-2"/>Verify Score
|
||||
</a>
|
||||
</t>
|
||||
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
|
||||
<i class="fa fa-arrow-left me-2"/>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
RESULTS PENDING
|
||||
=================================================================== -->
|
||||
<template id="results_pending" name="EnCoach Results Pending">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-pending-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8 text-center">
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-5">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-warning bg-opacity-10 mb-4" style="width:80px;height:80px;">
|
||||
<i class="fa fa-clock-o fa-2x text-warning"/>
|
||||
</span>
|
||||
<h3 class="fw-bold mb-2">Results Not Yet Released</h3>
|
||||
<p class="text-muted mb-4">
|
||||
Your exam (<strong t-esc="attempt.display_name"/>) is still being processed.
|
||||
Please check back later.
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center">
|
||||
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
|
||||
<i class="fa fa-arrow-left me-2"/>Dashboard
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary rounded-3 px-4" onclick="location.reload();">
|
||||
<i class="fa fa-refresh me-2"/>Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
PROFILE PAGE
|
||||
=================================================================== -->
|
||||
<template id="profile" name="EnCoach Profile">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-profile-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-md-9">
|
||||
|
||||
<h2 class="fw-bold mb-4">My Profile</h2>
|
||||
|
||||
<!-- Profile Form -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Personal Information</h5>
|
||||
<form action="/my/profile" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="prof_name" class="form-label fw-semibold">Full Name</label>
|
||||
<input type="text" id="prof_name" name="name" class="form-control rounded-3" t-att-value="user.name" required="required"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="prof_email" class="form-label fw-semibold">Email</label>
|
||||
<input type="email" id="prof_email" class="form-control rounded-3 bg-light" t-att-value="user.email" readonly="readonly"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="prof_phone" class="form-label fw-semibold">Phone</label>
|
||||
<input type="tel" id="prof_phone" name="phone" class="form-control rounded-3" t-att-value="user.phone or ''"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="prof_prefs" class="form-label fw-semibold">Study Preferences</label>
|
||||
<select id="prof_prefs" name="study_preference" class="form-select rounded-3">
|
||||
<option value="self_paced" t-att-selected="profile and profile.study_mode == 'self_paced'">Self-paced</option>
|
||||
<option value="structured" t-att-selected="profile and profile.study_mode == 'structured'">Structured</option>
|
||||
<option value="intensive" t-att-selected="profile and profile.study_mode == 'intensive'">Intensive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary rounded-3 mt-4 px-4">
|
||||
<i class="fa fa-save me-2"/>Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Change Password</h5>
|
||||
<form action="/my/profile/password" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="mb-3">
|
||||
<label for="current_pw" class="form-label fw-semibold">Current Password</label>
|
||||
<input type="password" id="current_pw" name="current_password" class="form-control rounded-3" required="required"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_pw" class="form-label fw-semibold">New Password</label>
|
||||
<input type="password" id="new_pw" name="new_password" class="form-control rounded-3" required="required" minlength="8"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_pw" class="form-label fw-semibold">Confirm New Password</label>
|
||||
<input type="password" id="confirm_pw" name="confirm_new_password" class="form-control rounded-3" required="required"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary rounded-3 px-4">
|
||||
<i class="fa fa-key me-2"/>Update Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entity Info -->
|
||||
<t t-if="profile and profile.entity_id">
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Organisation</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 me-3" style="width:48px;height:48px;">
|
||||
<i class="fa fa-building text-primary"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0" t-esc="profile.entity_id.name"/>
|
||||
<small class="text-muted" t-esc="profile.entity_id.name or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================================================================
|
||||
SCORE VERIFICATION (PUBLIC)
|
||||
=================================================================== -->
|
||||
<template id="score_verification" name="EnCoach Score Verification">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="oe_structure">
|
||||
<section class="ec-verification-section py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-4 p-md-5 text-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-success bg-opacity-10 mb-4" style="width:80px;height:80px;">
|
||||
<i class="fa fa-shield fa-2x text-success"/>
|
||||
</span>
|
||||
<h2 class="fw-bold mb-2">Score Verification</h2>
|
||||
<p class="text-muted mb-4">Verify the authenticity of an EnCoach score report</p>
|
||||
|
||||
<div class="bg-light rounded-3 p-3 mb-4">
|
||||
<small class="text-muted d-block mb-1">Verification Code</small>
|
||||
<code class="fs-5" t-esc="hash_code"/>
|
||||
</div>
|
||||
|
||||
<!-- Verification result loaded dynamically via JS/RPC -->
|
||||
<div id="ec_verification_result">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Verifying…</span>
|
||||
</div>
|
||||
<p class="text-muted mt-2">Looking up results…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user