feat: paid certifications on courses

This commit is contained in:
Jannat Patel
2025-02-21 19:12:20 +05:30
parent 4e6c1478f9
commit bacfaf4a71
17 changed files with 243 additions and 220 deletions

View File

@@ -100,9 +100,15 @@
<CourseInstructors :instructors="course.instructors" />
</div>
<div class="font-semibold">
<div v-if="course.paid_course" class="font-semibold">
{{ course.price }}
</div>
<div
v-if="course.paid_certificate"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
>
{{ __('Paid Certificate') }}
</div>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
class="rounded-t-md min-h-56 w-full"
/>
<div class="p-5">
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }}
</div>
<router-link
@@ -113,12 +113,21 @@
{{ course.data.rating }} {{ __('Rating') }}
</span>
</div>
<div
v-if="course.data.paid_certificate"
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
{{ __('Paid Certificate') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next'
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'

View File

@@ -38,7 +38,7 @@
<div class="flex mt-2">
<Star
v-for="index in 5"
class="h-5 w-5 text-ink-gray-2 rounded-sm mr-2"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
:class="
index <= Math.ceil(review.rating)
? 'fill-orange-500'

View File

@@ -21,11 +21,11 @@
<div
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
>
<div class="flex items-center justify-between space-x-2">
<div class="flex flex-col space-y-2">
<div class="text-ink-gray-5">
{{ __('Ordered Item') }}
{{ __('Payment for ') }} {{ type }}:
</div>
<div class="">
<div class="leading-5">
{{ orderSummary.data.title }}
</div>
</div>
@@ -126,7 +126,7 @@
<p class="text-ink-gray-5">
{{
__(
'Make sure to enter the right billing name as the same will be used in your invoice.'
'Make sure to enter the correct billing name as the same will be used in your invoice.'
)
}}
</p>
@@ -140,10 +140,10 @@
<div v-else-if="access.data?.message">
<NotPermitted
:text="access.data.message"
:buttonLabel="
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
:buttonLink="
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
"
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
/>
</div>
<div v-else-if="!user.data?.name">
@@ -163,7 +163,7 @@ import {
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue'
import { reactive, inject, onMounted, computed } from 'vue'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { showToast } from '@/utils/'
@@ -193,7 +193,7 @@ const props = defineProps({
const access = createResource({
url: 'lms.lms.api.validate_billing_access',
params: {
type: props.type,
billing_type: props.type,
name: props.name,
},
onSuccess(data) {
@@ -206,7 +206,7 @@ const orderSummary = createResource({
url: 'lms.lms.utils.get_order_summary',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
country: billingDetails.country,
}
@@ -236,13 +236,15 @@ const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
redirect_to: redirectTo,
payment_for_certificate: props.type == 'certificate',
}
},
})
@@ -347,4 +349,14 @@ const changeCurrency = (country) => {
billingDetails.country = country
orderSummary.reload()
}
const redirectTo = computed(() => {
if (props.type == 'course') {
return `/lms/courses/${props.name}`
} else if (props.type == 'batch') {
return `/lms/batches/${props.name}`
} else if (props.type == 'certificate') {
return `/lms/courses/${props.name}/certification`
}
})
</script>

View File

@@ -0,0 +1,2 @@
<template>Course Certificate</template>
<script setup></script>

View File

@@ -160,7 +160,7 @@
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-10 mb-4">
<div class="grid grid-cols-2 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-4"
@@ -188,35 +188,38 @@
v-model="course.featured"
:label="__('Featured')"
/>
</div>
<div class="flex flex-col space-y-3">
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
</div>
</div>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Pricing') }}
{{ __('Pricing and Certification') }}
</div>
<div class="mb-4">
<div class="grid grid-cols-3 mb-4">
<FormControl
type="checkbox"
v-model="course.paid_course"
:label="__('Paid Course')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
<FormControl
type="checkbox"
v-model="course.paid_certificate"
:label="__('Paid Certificate')"
/>
</div>
<FormControl
v-model="course.course_price"
:label="__('Course Price')"
:label="__('Amount')"
class="mb-4"
/>
<Link

View File

@@ -4,6 +4,45 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div
v-if="
lesson.data && lesson.data.membership && lesson.data.paid_certificate
"
>
<router-link
v-if="!lesson.data.membership.purchased_certificate"
:to="{
name: 'Billing',
params: {
type: 'certificate',
name: courseName,
},
}"
>
<Button>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
<router-link
v-else-if="!lesson.data.membership.certficate"
:to="{
name: 'CourseCertification',
params: {
courseName: courseName,
},
}"
>
<Button>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
</div>
</header>
<div class="grid md:grid-cols-[70%,30%] h-screen">
<div
@@ -197,7 +236,7 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils'
import EditorJS from '@editorjs/editorjs'

View File

@@ -28,6 +28,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'),
props: true,
},
{
path: '/courses/:courseName/certification',
name: 'CourseCertification',
component: () => import('@/pages/CourseCertification.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter',

View File

@@ -190,24 +190,24 @@ def get_translations():
@frappe.whitelist()
def validate_billing_access(type, name):
def validate_billing_access(billing_type, name):
access = True
message = ""
doctype = "LMS Course" if type == "course" else "LMS Batch"
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
if frappe.session.user == "Guest":
access = False
message = _("Please login to continue with payment.")
if type not in ["course", "batch"]:
if access and billing_type not in ["course", "batch", "certificate"]:
access = False
message = _("Module is incorrect.")
if not frappe.db.exists(doctype, name):
if access and not frappe.db.exists(doctype, name):
access = False
message = _("Module Name is incorrect or does not exist.")
if type == "course":
if access and billing_type == "course":
membership = frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": name}
)
@@ -215,7 +215,7 @@ def validate_billing_access(type, name):
access = False
message = _("You are already enrolled for this course.")
else:
elif access and billing_type == "batch":
membership = frappe.db.exists(
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
)
@@ -223,6 +223,19 @@ def validate_billing_access(type, name):
access = False
message = _("You are already enrolled for this batch.")
elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists(
"LMS Enrollment",
{
"course": name,
"member": frappe.session.user,
"purchased_certificate": 1,
},
)
if purchased_certificate:
access = False
message = _("You have already purchased the certificate for this course.")
address = frappe.db.get_value(
"Address",
{"email_id": frappe.session.user},
@@ -370,7 +383,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
def get_certified_participants(filters=None, start=0, page_length=30):
or_filters = {}
if not filters:
filters = {}

View File

@@ -10,7 +10,6 @@ from datetime import timedelta
from frappe.model.document import Document
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
from lms.lms.utils import (
get_lessons,
get_lesson_index,
get_lesson_url,
get_quiz_details,
@@ -258,17 +257,6 @@ def create_batch(
return doc
@frappe.whitelist()
def fetch_lessons(courses):
lessons = []
courses = json.loads(courses)
for course in courses:
lessons.extend(get_lessons(course.get("course")))
return lessons
@frappe.whitelist()
def add_course(course, parent, name=None, evaluator=None):
frappe.only_for("Moderator")

View File

@@ -40,15 +40,12 @@
"pricing_tab",
"pricing_section",
"paid_course",
"enable_certification",
"paid_certificate",
"column_break_acoj",
"course_price",
"currency",
"amount_usd",
"certification_tab",
"certification_section",
"enable_certification",
"column_break_rxww",
"expiry",
"tab_4_tab",
"statistics_section",
"enrollments",
@@ -134,22 +131,11 @@
"fieldtype": "Section Break",
"label": "Course Settings"
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "enable_certification",
"fieldtype": "Check",
"label": "Enable Certification"
},
{
"default": "0",
"depends_on": "enable_certification",
"fieldname": "expiry",
"fieldtype": "Int",
"label": "Certification Expires After (Years)"
"label": "Completion Certificate"
},
{
"fieldname": "related_courses",
@@ -181,7 +167,6 @@
"fieldtype": "Section Break"
},
{
"depends_on": "paid_course",
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
@@ -195,22 +180,16 @@
"label": "Paid Course"
},
{
"depends_on": "paid_course",
"fieldname": "course_price",
"fieldtype": "Currency",
"label": "Course Price",
"label": "Amount",
"mandatory_depends_on": "paid_course"
},
{
"fieldname": "column_break_rxww",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_acoj",
"fieldtype": "Column Break"
},
{
"depends_on": "paid_course",
"description": "If you set an amount here, then the USD equivalent setting will not get applied.",
"fieldname": "amount_usd",
"fieldtype": "Currency",
@@ -238,12 +217,7 @@
{
"fieldname": "pricing_tab",
"fieldtype": "Tab Break",
"label": "Pricing"
},
{
"fieldname": "certification_tab",
"fieldtype": "Tab Break",
"label": "Certification"
"label": "Pricing and Certification"
},
{
"fieldname": "column_break_htgn",
@@ -284,6 +258,12 @@
"fieldtype": "Data",
"label": "Rating",
"read_only": 1
},
{
"default": "0",
"fieldname": "paid_certificate",
"fieldtype": "Check",
"label": "Paid Certificate"
}
],
"is_published_field": "published",
@@ -310,7 +290,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-10-30 23:08:31.842860",
"modified": "2025-02-20 16:44:38.891383",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -5,9 +5,8 @@ import json
import random
import frappe
from frappe.model.document import Document
from frappe.utils import cint, today
from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses
from frappe.utils import today, cint
from lms.lms.utils import get_chapters
from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _
@@ -53,9 +52,12 @@ class LMSCourse(Document):
frappe.throw(_("Please install the Payments app to create a paid courses."))
def validate_amount_and_currency(self):
if self.paid_course and (not self.course_price and not self.currency):
if self.paid_course and (cint(self.course_price) < 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid courses."))
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid certificates."))
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()

View File

@@ -14,6 +14,9 @@
"member",
"member_name",
"member_username",
"certification_section",
"purchased_certificate",
"certificate",
"section_break_8",
"cohort",
"subgroup",
@@ -123,11 +126,28 @@
"fieldtype": "Link",
"label": "Payment",
"options": "LMS Payment"
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break",
"label": "Certification"
},
{
"default": "0",
"fieldname": "purchased_certificate",
"fieldtype": "Check",
"label": "Purchased Certificate"
},
{
"fieldname": "certificate",
"fieldtype": "Link",
"label": "Certificate",
"options": "LMS Certificate"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-10-30 12:44:16.103598",
"modified": "2025-02-21 17:11:37.986157",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",

View File

@@ -14,6 +14,7 @@
"payment_for_document_type",
"payment_for_document",
"payment_received",
"payment_for_certificate",
"payment_details_section",
"currency",
"amount",
@@ -136,6 +137,12 @@
"fieldtype": "Link",
"label": "Source",
"options": "LMS Source"
},
{
"default": "0",
"fieldname": "payment_for_certificate",
"fieldtype": "Check",
"label": "Payment for Certificate"
}
],
"index_web_pages_for_search": 1,
@@ -149,7 +156,7 @@
"link_fieldname": "payment"
}
],
"modified": "2025-02-18 15:54:25.383353",
"modified": "2025-02-21 18:29:55.436611",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Payment",

View File

@@ -18,19 +18,26 @@ def validate_currency(payment_gateway, currency):
@frappe.whitelist()
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address):
def get_payment_link(
doctype,
docname,
title,
amount,
total_amount,
currency,
address,
redirect_to,
payment_for_certificate,
):
payment_gateway = get_payment_gateway()
address = frappe._dict(address)
amount_with_gst = total_amount if total_amount != amount else 0
payment = record_payment(address, doctype, docname, amount, currency, amount_with_gst)
payment = record_payment(
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
)
controller = get_controller(payment_gateway)
if doctype == "LMS Course":
redirect_to = f"/lms/courses/{docname}/learn/1-1"
elif doctype == "LMS Batch":
redirect_to = f"/lms/batches/{docname}"
payment_details = {
"amount": total_amount,
"title": f"Payment for {doctype} {title} {docname}",
@@ -53,7 +60,15 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
return url
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0):
def record_payment(
address,
doctype,
docname,
amount,
currency,
amount_with_gst=0,
payment_for_certificate=0,
):
address = frappe._dict(address)
address_name = save_address(address)
@@ -71,6 +86,7 @@ def record_payment(address, doctype, docname, amount, currency, amount_with_gst=
"source": address.source,
"payment_for_document_type": doctype,
"payment_for_document": docname,
"payment_for_certificate": payment_for_certificate,
}
)
payment_doc.save(ignore_permissions=True)

View File

@@ -68,27 +68,26 @@ def generate_slug(title, doctype):
return slugify(title, used_slugs=slugs)
def get_membership(course, member=None, batch=None):
def get_membership(course, member=None):
if not member:
member = frappe.session.user
filters = {"member": member, "course": course}
if batch:
filters["batch_old"] = batch
is_member = frappe.db.exists("LMS Enrollment", filters)
if is_member:
if frappe.db.exists("LMS Enrollment", filters):
membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "batch_old", "current_lesson", "member_type", "progress", "member"],
[
"name",
"current_lesson",
"progress",
"member",
"purchased_certificate",
"certificate",
],
as_dict=True,
)
if membership and membership.batch_old:
membership.batch_title = frappe.db.get_value(
"LMS Batch Old", membership.batch_old, "title"
)
return membership
return False
@@ -1009,6 +1008,7 @@ def get_course_details(course):
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
@@ -1023,7 +1023,7 @@ def get_course_details(course):
course_details.instructors = get_instructors(course_details.name)
# course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course:
if course_details.paid_course or course_details.paid_certificate:
"""course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd
)"""
@@ -1136,14 +1136,21 @@ def get_lesson(course, chapter, lesson):
return {}
membership = get_membership(course)
course_title = frappe.db.get_value("LMS Course", course, "title")
course_info = frappe.db.get_value(
"LMS Course", course, ["title", "paid_certificate"], as_dict=1
)
if (
not lesson_details.include_in_preview
and not membership
and not has_course_moderator_role()
and not is_instructor(course)
):
return {"no_preview": 1, "title": lesson_details.title, "course_title": course_title}
return {
"no_preview": 1,
"title": lesson_details.title,
"course_title": course_info.title,
}
lesson_details = frappe.db.get_value(
"Course Lesson",
@@ -1178,7 +1185,8 @@ def get_lesson(course, chapter, lesson):
lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership
lesson_details.instructors = get_instructors(course)
lesson_details.course_title = course_title
lesson_details.course_title = course_info.title
lesson_details.paid_certificate = course_info.paid_certificate
return lesson_details
@@ -1612,11 +1620,19 @@ def get_order_summary(doctype, docname, country=None):
details = frappe.db.get_value(
"LMS Course",
docname,
["title", "name", "paid_course", "course_price as amount", "currency", "amount_usd"],
[
"title",
"name",
"paid_course",
"paid_certificate",
"course_price as amount",
"currency",
"amount_usd",
],
as_dict=True,
)
if not details.paid_course:
if not details.paid_course and not details.paid_certificate:
raise frappe.throw(_("This course is free."))
else:
@@ -1730,9 +1746,14 @@ def update_payment_record(doctype, docname):
"order_id": data.get("order_id"),
},
)
payment_for_certificate = frappe.db.get_value(
"LMS Payment", data.payment, "payment_for_certificate"
)
try:
if doctype == "LMS Course":
if payment_for_certificate:
update_certificate_purchase(docname)
elif doctype == "LMS Course":
enroll_in_course(data.payment, docname)
else:
enroll_in_batch(docname, data.payment)
@@ -1792,6 +1813,15 @@ def enroll_in_batch(batch, payment_name=None):
new_student.save()
def update_certificate_purchase(course):
frappe.db.set_value(
"LMS Enrollment",
{"member": frappe.session.user, "course": course},
"purchased_certificate",
1,
)
@frappe.whitelist()
def get_programs():
if (

View File

@@ -1,110 +0,0 @@
{% set chapters = get_chapters(course.name) %}
{% set is_instructor = is_instructor(course.name) %}
{% if chapters | length %}
<div class="course-home-outline">
{% if not lesson_page %}
<div class="page-title mb-8" id="outline-heading" data-course="{{ course.name }}">
{{ _("Course Content") }}
</div>
<!-- <div class="mb-2">
<span>
{{ chapters | length }} chapters
</span>
<span>
. {{ get_lessons(course.name, None, False) }} lessons
</span>
</div> -->
{% endif %}
{% if chapters | length %}
<div>
{% for chapter in chapters %}
{% set lessons = get_lessons(course.name, chapter) %}
<div class="chapter-parent" data-chapter="{{ chapter.name }}">
<div class="chapter-title" data-toggle="collapse" aria-expanded="false"
data-target="#{{ get_slugified_chapter_title(chapter.title) }}">
<img class="chapter-icon" src="/assets/lms/icons/chevron-right.svg">
<div class="chapter-title-main">
{{ chapter.title }}
</div>
<!-- <div class="small ml-auto">
{{ lessons | length }} lessons
</div> -->
</div>
<div class="chapter-content collapse navbar-collapse" id="{{ get_slugified_chapter_title(chapter.title) }}">
{% if chapter.description %}
<div class="chapter-description">
{{ chapter.description }}
</div>
{% endif %}
<div class="lessons">
{% if lessons | length %}
{% for lesson in lessons %}
{% set active = membership.current_lesson == lesson.name %}
<div data-lesson="{{ lesson.name }}" class="lesson-info {% if active %} active-lesson {% endif %}">
{% if membership or lesson.include_in_preview or is_instructor or has_course_moderator_role() %}
<a class="lesson-links"
href="{{ get_lesson_url(course.name, lesson.number) }}{% if classname %}?class={{ classname }}{% endif %}{{course.query_parameter}}"
{% if is_instructor and not lesson.include_in_preview %}
title="{{ _('This lesson is not available for preview. As you are the Instructor of the course only you can see it.') }}"
{% endif %}>
<svg class="icon icon-sm mr-2">
<use class="" href="#{{ lesson.icon }}">
</svg>
<span>{{ lesson.title }}</span>
{% if membership %}
<svg class="icon icon-md lesson-progress-tick ml-auto {{ get_progress(course.name, lesson.name) != 'Complete' and 'hide' }}">
<use class="" href="#icon-success">
</svg>
{% endif %}
</a>
{% else %}
<div class="no-preview" title="This lesson is not available for preview">
<div class="lesson-links">
<svg class="icon icon-sm mr-2">
<use class="" href="#icon-lock-gray">
</svg>
<div>{{ lesson.title }}</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if chapters | length %}
<!-- No Preview Modal -->
{{ widgets.NoPreviewModal(course=course, membership=membership) }}
{% endif %}