This page covers all backend functions for courses and shows how to use them in .ef page templates. See Courses — Overview for setup and enrollment.
Backend functions
All functions are available in <script scope="backend"> blocks and in .ef files.
getCourses(opts?)
Returns all courses for the brand, each with is_assigned and started for the current customer.
| Parameter | Type | Default | Description |
|---|
opts | object | {} | Preferred: { assignedOnly, sort, preferredOrder } |
// Preferred object style
var allCourses = getCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
var myCourses = getCourses({ assignedOnly: true });
// Legacy positional style (still supported for backward compatibility)
var allCoursesLegacy = getCourses(false);
var myCoursesLegacy = getCourses(true);
sort supports:
enrollment_oldest_unfinished_first (default)
title_asc
title_desc
preferred (when used, pass preferredOrder: ['slug-or-title', ...])
getCategoriesWithCourses(opts?)
Same as getCourses but grouped by category. Returns an array of { category_slug, category_name, courses[] }.
var categories = getCategoriesWithCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
// categories[0].category_name → "Daily Practices"
// categories[0].courses → [ { title, slug, is_assigned, ... } ]
// Legacy positional style still supported:
var categoriesLegacy = getCategoriesWithCourses(false);
getCoursesByCategorySlug(opts)
Returns courses in a single category. Useful for “Daily practices” or “Advanced” sections on a dashboard.
var daily = getCoursesByCategorySlug({
category: 'daily-practices',
assignedOnly: false,
sort: 'enrollment_oldest_unfinished_first'
});
// Legacy positional style still supported:
var dailyLegacy = getCoursesByCategorySlug('daily-practices', false);
getCoursesForCustomer(opts?)
Returns only courses the current customer is enrolled in (same as getCourses({ assignedOnly: true })). Returns [] if no session or no enrollments.
var enrolled = getCoursesForCustomer({ sort: 'enrollment_oldest_unfinished_first' });
// Legacy style still supported:
var enrolledLegacy = getCoursesForCustomer();
getCourseForCustomer(opts)
Returns a single course with enrollment check — is_assigned and started are set for the current customer. Also returns the full modules[] and contents[] tree, plus progress_percent and completed_content_ids. Use this on the course detail page.
var course = getCourseForCustomer({ slug: 'tinnitus-recovery' });
// course.is_assigned → true / false
// course.modules → full module/lesson tree
// course.progress_percent → 0–100
// Legacy positional style still supported:
var courseLegacy = getCourseForCustomer('tinnitus-recovery');
Returns null if no course with that slug exists.
getCourseBySlug(slug)
Returns a single course without enrollment check. is_assigned and started will not be set. Use when you only need course metadata and don’t need to gate content.
var course = getCourseBySlug('tinnitus-recovery');
getCourseLesson(opts)
Returns the resolved current lesson payload for lesson-player pages, including currentLesson, prevLessonUrl, nextLessonUrl, and progress stats.
var lessonSeed = getCourseLesson({
course: 'tinnitus-recovery',
module: 12,
lesson: 77
});
// Legacy positional style still supported:
var lessonSeedLegacy = getCourseLesson('tinnitus-recovery', 12, 77);
getCompletedLessonsCount(opts?)
Returns the total number of completed lessons across all enrolled courses, or for a specific course when a slug is provided.
| Parameter | Type | Default | Description |
|---|
opts | object | undefined | Optional. Pass { course: 'slug' } to count only a specific course. Omit for all courses. |
var totalDone = getCompletedLessonsCount(); // all courses
var courseDone = getCompletedLessonsCount({ course: 'tinnitus-recovery' }); // single course
// Legacy shorthand still supported:
var courseDoneLegacy = getCompletedLessonsCount('tinnitus-recovery');
Returns 0 if the customer has no enrollments or no completed lessons.
getCourseProgress(opts?)
Returns a structured progress summary with completed/total lesson counts per course and an overall total. Useful for dashboards.
| Parameter | Type | Default | Description |
|---|
opts | object | undefined | Optional. Pass { course: 'slug' } to limit to a single course. Omit for all courses. |
var progress = getCourseProgress();
// progress.completedLessons → 12 (total across all courses)
// progress.totalLessons → 48 (total across all courses)
// progress.courses → [
// { slug: 'tinnitus-recovery', title: '...', completedLessons: 5, totalLessons: 16, progressPercent: 31 },
// { slug: 'cbt-program', title: '...', completedLessons: 7, totalLessons: 32, progressPercent: 22 }
// ]
var single = getCourseProgress({ course: 'tinnitus-recovery' });
// single.completedLessons → 5
// single.courses → [ { slug: 'tinnitus-recovery', ... } ]
// Legacy shorthand still supported:
var singleLegacy = getCourseProgress('tinnitus-recovery');
request.route_params
For dynamic slug pages (e.g. guided-course/{course}.ef), the slug is available via request.route_params.course.
Course list page
Show all courses, sorted so enrolled courses appear first. Use is_assigned and started to drive the CTA label.
<script scope="backend">
if (!is_customer) {
redirect('/members-login');
}
var allCourses = getCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
// Sort: enrolled first, then among enrolled put started (Continue) before not started
allCourses = allCourses.slice().sort(function(a, b) {
if (a.is_assigned !== b.is_assigned) return b.is_assigned - a.is_assigned;
return b.started - a.started;
});
setVariable('courses', allCourses);
setVariable('courseCount', allCourses.length);
</script>
@if(var.courseCount gt 0)
<div class="course-grid">
@foreach(c in var.courses)
<div class="course-card">
@if(c.image_url)
<img src="{{ c.image_url }}" alt="{{ c.title }}" />
@endif
<h3>{{ c.title }}</h3>
<p>{{ c.description }}</p>
<p>{{ c.module_count }} modules · {{ c.lesson_count }} lessons</p>
@if(c.is_assigned eq false)
<a href="/buy/{{ c.slug }}-access" class="btn-secondary">Get access</a>
@elseif(c.started)
<a href="/course/{{ c.slug }}" class="btn-primary">Continue</a>
@else
<a href="/course/{{ c.slug }}" class="btn-primary">Start Course</a>
@endif
</div>
@endforeach
</div>
@else
<p>No courses available yet.</p>
@endif
With category tabs
<script scope="backend">
if (!is_customer) { redirect('/members-login'); }
var categories = getCategoriesWithCourses({ assignedOnly: false, sort: 'enrollment_oldest_unfinished_first' });
// Sort courses within each category: assigned first
categories = categories.map(function(cat) {
var sorted = (cat.courses || []).slice().sort(function(a, b) {
if (a.is_assigned !== b.is_assigned) return b.is_assigned - a.is_assigned;
return b.started - a.started;
});
return { category_slug: cat.category_slug, category_name: cat.category_name, courses: sorted };
});
setVariable('categories', categories);
</script>
@foreach(cat in var.categories)
<section data-category="{{ cat.category_slug }}">
<h2>{{ cat.category_name }}</h2>
<div class="course-grid">
@foreach(c in cat.courses)
<div class="course-card">
<h3><a href="/course/{{ c.slug }}">{{ c.title }}</a></h3>
<span>
@if(c.is_assigned eq false)
Get access
@elseif(c.started)
Continue
@else
Start Course
@endif
</span>
</div>
@endforeach
</div>
</section>
@endforeach
Course detail page
Resolve the slug from the URL, check enrollment with getCourseForCustomer, and gate content.
<script scope="backend">
if (!is_customer) {
redirect('/members-login');
}
// For a dynamic page named guided-course/{course}.ef:
var slug = request.route_params.course;
// For a fixed page reading slug from query string:
// var slug = request.query.course;
var course = getCourseForCustomer({ slug: slug });
if (!course) {
redirect('/courses');
}
setVariable('course', course);
setVariable('isAssigned', course.is_assigned || false);
setVariable('courseStarted', course.started || false);
if (course.is_assigned) {
// Track that the customer has visited this course (optional)
setSessionItem('course_visited_' + slug, '1');
}
</script>
@if(var.course)
<!-- Hero -->
<div class="course-hero">
@if(var.course.cover)
<img src="{{ var.course.cover }}" alt="{{ var.course.title }}" />
@endif
<h1>{{ var.course.title }}</h1>
@if(var.course.instructor_name)
<p>by {{ var.course.instructor_name }}</p>
@endif
</div>
<!-- Stats -->
<div class="course-stats">
<span>{{ var.course.module_count }} modules</span>
<span>{{ var.course.total_lesson_count }} lessons</span>
</div>
@if(var.isAssigned)
<!-- Progress -->
<div class="progress-bar" style="width: {{ var.course.progress_percent }}%"></div>
<p>{{ var.course.progress_percent }}% complete</p>
<!-- Modules and lessons -->
@foreach(mod in var.course.modules)
<div class="module" id="module-{{ mod.id }}">
<h2>{{ mod.order }}. {{ mod.title }}</h2>
<div class="lessons">
@foreach(lesson in mod.contents)
<div class="lesson-row"
data-content-id="{{ lesson.id }}"
data-content-url="{{ lesson.content_url }}">
<span class="lesson-title">{{ lesson.title }}</span>
@if(lesson.type == 'video')
<span class="badge">Video</span>
@elseif(lesson.type == 'audio')
<span class="badge">Audio</span>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
@else
<!-- Not enrolled — show CTA -->
<div class="access-gate">
<p>You don't have access to this course yet.</p>
<a href="/courses" class="btn">Browse all courses</a>
</div>
@endif
@else
<p>Course not found. <a href="/courses">Back to courses</a></p>
@endif
Auth wrapper for course pages
Use the auth wrapper pattern (@extends) so the login check and common data load happen once across all course pages:
{{-- members-courses-layout.ef --}}
<script scope="backend">
if (!is_customer) {
redirect('/members-login?next=' + encodeURIComponent(request.path));
}
</script>
@block("content") @endblock
Then each course page:
{{-- course-list.ef --}}
@extends("members-courses-layout")
@block("content")
<script scope="backend">
var courses = getCourses({ assignedOnly: false });
setVariable('courses', courses);
</script>
@foreach(c in var.courses)
<p>{{ c.title }} — @if(c.is_assigned)Enrolled @else Get access @endif</p>
@endforeach
@endblock
Checking access to a specific product
If you want to gate course access by checking whether the customer bought a specific product (rather than relying on enrollment), you can cross-reference with getOrders():
<script scope="backend">
if (!is_customer) { redirect('/members-login'); }
var course = getCourseForCustomer({ slug: request.route_params.course });
if (!course) { redirect('/courses'); }
// Fallback: check order history for a product by name
if (!course.is_assigned) {
var orders = getOrders('newest', 100);
for (var i = 0; i < orders.length; i++) {
for (var j = 0; j < orders[i].products.length; j++) {
if (orders[i].products[j].name === 'Course Access Pass') {
course.is_assigned = true;
}
}
}
}
setVariable('course', course);
setVariable('isAssigned', course.is_assigned);
</script>
Prefer the enrollment-based check (getCourseForCustomer) — it is more reliable and does not require iterating order history. Use the order-history fallback only if enrollment is not being set up via product linking.
Lesson types and content
Each lesson in mod.contents has a type field:
type | Content fields |
|---|
'video' | content_url (direct video URL) or content_embed (iframe HTML) |
'audio' | content_url (audio file URL) |
'text' | content (HTML body) |
Additional fields on each lesson:
| Field | Description |
|---|
title | Lesson title |
description | Short description shown below the player |
content_under | Additional text shown under video/audio (e.g. transcript excerpt) |
materials | Array of { name, url } — downloadable files |
key_takeaways | Array of strings — bullet-point summary |
Progress API
All progress routes are prefixed with /api/course/courses/:courseId/. Authentication is via the active course session (cookie). courseId, moduleId, and contentId are numeric IDs.
Mark a lesson complete
// POST /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/complete
fetch(`/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
Returns { success: true, message, stats: { completed_lessons, total_lessons, completion_percent } }.
Save general progress
courseId, moduleId, and contentId go in the URL for the lesson-specific routes. The general saveProgress route keeps them in the body — useful when you want to upsert a progress record with a specific completed value (e.g. un-completing).
// POST /api/course/courses/:courseId/progress
fetch(`/api/course/courses/${courseId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
module_id: moduleId, // required
content_id: contentId, // required
completed: true, // boolean
progress_percent: 100, // 0–100
video_position: 0, // seconds
video_duration: 0 // seconds
})
});
Save video position
// POST /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/video-progress
fetch(`/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/video-progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_position: 142, // seconds elapsed
video_duration: 600, // total duration in seconds
progress_percent: 24 // 0–100; auto-completes lesson at ≥ 90%
})
});
Get all progress for a course
// GET /api/course/courses/:courseId/progress
const res = await fetch(`/api/course/courses/${courseId}/progress`);
const { progress, stats } = await res.json();
// progress → array of per-lesson progress objects
// stats → { completed_lessons, total_lessons, completion_percent, last_accessed_at, ... }
Get progress for a single lesson
// GET /api/course/courses/:courseId/modules/:moduleId/lessons/:contentId/progress
const res = await fetch(
`/api/course/courses/${courseId}/modules/${moduleId}/lessons/${contentId}/progress`
);
const { progress } = await res.json();
// progress → { completed, progress_percent, video_position, video_duration } or null
Get course stats
// GET /api/course/courses/:courseId/stats
const res = await fetch(`/api/course/courses/${courseId}/stats`);
const { stats } = await res.json();
The page receives the initial completed IDs via course.completed_content_ids. Use this array to mark lessons visually on load, then update the UI optimistically when the API call succeeds.