Documentation Index
Fetch the complete documentation index at: https://docs.elasticfunnels.io/llms.txt
Use this file to discover all available pages before exploring further.
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.