> ## 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.

# Courses — Template Reference

> Backend functions and template patterns for course list pages, course detail pages, and access gating

This page covers all backend functions for courses and shows how to use them in `.ef` page templates. See [Courses — Overview](/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 }` |

```javascript theme={null}
// 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[] }`.

```javascript theme={null}
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.

```javascript theme={null}
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.

```javascript theme={null}
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.

```javascript theme={null}
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.

```javascript theme={null}
var course = getCourseBySlug('tinnitus-recovery');
```

### `getCourseLesson(opts)`

Returns the resolved current lesson payload for lesson-player pages, including `currentLesson`, `prevLessonUrl`, `nextLessonUrl`, and progress stats.

```javascript theme={null}
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. |

```javascript theme={null}
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. |

```javascript theme={null}
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.

```html theme={null}
<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

```html theme={null}
<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.

```html theme={null}
<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](/members-area/overview#auth-wrapper-pattern-recommended) (`@extends`) so the login check and common data load happen once across all course pages:

```html theme={null}
{{-- 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:

```html theme={null}
{{-- 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()`:

```html theme={null}
<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>
```

<Note>
  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.
</Note>

***

## 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

```javascript theme={null}
// 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).

```javascript theme={null}
// 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

```javascript theme={null}
// 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

```javascript theme={null}
// 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

```javascript theme={null}
// 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

```javascript theme={null}
// 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.
