ArticleSales pagesLiquid
A course sales page that keeps itself up to date
The free course's sales page reads the course from Teachable every time it loads. Add a lesson or change a price and the page follows, without anyone touching it. Along the way: what Teachable's Liquid really gives you, tested on a live school.
Most sales pages are written once and then slowly go wrong. A lesson gets added, a price changes, and the sales page still shows the course as it was on launch day.
The sales page for the free course doesn't have that problem. Its curriculum, its counts and its main button are built from the course itself, using Liquid, Teachable's template language. This article covers how, and what Teachable's Liquid will and won't give you. Most of that isn't in Teachable's documentation, so everything here was tested on this school.
Six blocks, four of them live
The page is six custom code blocks, one per section:
| Block | Reads the course? |
|---|---|
| Hero and price card | Yes: section and lesson counts, and the first lesson |
| What you'll learn | No, written by hand |
| Curriculum | Yes: every section and lesson, with times and free previews |
| About the author | Yes: the course's author details |
| Questions | No, written by hand |
| Closing call to action | Yes: the first lesson |
The hand-written blocks are the easy part: plain HTML, and the questions
open and close with <details>, so they need no script.
The rest of this article is about the ones that read the course.
Two ways to find the course
A sales page that belongs to a course gets that course handed to it, as
course. Liquid calls these bundles of details "drops": a course
drop, a school drop, a user drop.
<h1>{{ course.name }}</h1>
Anywhere else (the home page, a custom page like this one) there's no course in hand, so you have to go and find it. Teachable gives you a list of the school's visible courses, but no way to ask for one by its number, so you go through the list until the ID matches:
{%- assign target_course_id = 3033045 -%}
{%- assign ph_course = nil -%}
{%- for c in current_school.publicly_viewable_courses -%}
{%- if c.id == target_course_id -%}{%- assign ph_course = c -%}{%- endif -%}
{%- endfor -%}
The course ID is the number in the course's address in your Teachable admin. Change that one number and the block shows a different course.
A course found this way carries almost everything the page's own
course does: sections, lessons, author, prices, and whether the
person looking is enrolled. The free course's sales blocks all use the
loop, even on the course's own page. A block that finds its own course can
be pasted anywhere.
There's also current_school.courses, which sounds more
complete. It is, too complete: on this school it counts 71 courses, far more
than the admin shows, because deleted courses are still in it. Stick to
publicly_viewable_courses.
Lessons come section by section
There's no list of a course's lessons. There's a list of its sections, and each section has its lessons. So counting lessons means a loop inside a loop:
{%- assign ph_sections = 0 -%}
{%- assign ph_lessons = 0 -%}
{%- for section in ph_course.ordered_published_lecture_sections -%}
{%- assign ph_sections = ph_sections | plus: 1 -%}
{%- assign ph_lessons = ph_lessons | plus: section.lectures.size -%}
{%- endfor -%}
<li><strong>{{ ph_sections }}</strong> sections</li>
<li><strong>{{ ph_lessons }}</strong> lessons</li>
There are two lists of sections, and they don't just filter differently: they hand you two different kinds of lesson.
lecture_sections | ordered_published_lecture_sections | |
|---|---|---|
| In Teachable's docs | Yes | No |
| Sections | All, including unpublished | Published only, in your order |
| Lesson address | By number | By the course's address name |
| Video length | Blank | In seconds |
So the rule is simple: always use
ordered_published_lecture_sections, and don't mix the two. The
documented one looks like the obvious choice and quietly gives you less.
Teachable, without the limits, right now
This box found the course with the loop above and counted it as this page loaded: 9 sections and 59 lessons, every one of them open to preview. Written by Tom Lorimer.
Live prices: Free (FREE).
The first lesson is What Teachable actually is.
Add a lesson or change a price, and this box changes the next time anyone opens the page.
A curriculum that updates itself
The curriculum block goes through the same sections and lessons, but writes out each one instead of just counting:
{%- for section in ph_course.ordered_published_lecture_sections -%}
<h3>{{ section.name }}</h3>
<ul>
{%- for lecture in section.lectures -%}
<li>
<a href="/courses/{{ ph_course.id }}/lectures/{{ lecture.id }}">{{ lecture.name }}</a>
{%- if lecture.free_preview %} <span>Free preview</span>{% endif %}
</li>
{%- endfor -%}
</ul>
{%- endfor -%}
The full version adds a few extras on top:
-
Running times.
lecture.durationis the video length in seconds, adding up to "2h 15m" style totals per section and for the course. It's only there for video lessons, and only from the ordered list. -
An icon for each lesson type.
lecture.icon_v3holds the name of the icon Teachable shows for the lesson (Video,Codeand so on), which is enough to choose your own. - Fewer labels when everything's free. If every lesson is a free preview, a "Free preview" tag on each one is just noise, so the block counts them first and leaves the tags off.
Lessons also have a position, but don't number lessons with it.
After reordering, one lesson on this school came back as position 0 while
its neighbours were 2, 3 and 4. The order of the list is right; the numbers
aren't.
Prices, and why their order used to go wrong
Each course has a list of its pricing plans in products. Each
plan has a name, a ready-to-show price (formatted_price, such as
"$127"), the price as a number in cents (price), and whether
it's live (is_published).
Showing a course's prices dynamically has long had a reputation for being unreliable on Teachable, with plans turning up in the wrong order. Testing showed why:
- Old plans never leave the list. Every plan the course has ever had is in it, archived ones included. One course on this school lists twelve plans, with one live.
- Only live plans get renumbered. Drag a plan to the top in the admin and the live plans become 1, 2, 3. The archived ones keep their old numbers, so two plans can share a position, and which of them comes first is down to chance.
Take the archived plans out first and the live ones come back cleanly, in the order you set in the admin:
{%- assign live_plans = course.products | where: 'is_published', true | sort: 'position' -%}
<ul>
{%- for p in live_plans %}
<li>{{ p.name }}: {{ p.formatted_price }}</li>
{%- endfor %}
</ul>
The list already comes back in position order, so the sort is a
safety net in case that ever changes. where and
sort both work on Teachable, though neither is in the
documentation.
A "From $39" line
Sorting by price puts the cheapest plan first. Two catches:
- Payment plans sort by the instalment. "3 payments of $69" sorts as $69, below a $97 one-off price, though it costs $207 in all. Leave them out for a "From" price.
- Free plans sort first. A hidden free plan for enrolling people by hand would become the "From" price. Filtering to live plans first stops that.
{%- assign one_off = course.products | where: 'is_published', true | where: 'is_recurring', false | sort: 'price' -%}
From {{ one_off.first.formatted_price }}
billing_interval says "month" even on one-off prices, so only
read it when is_recurring is true.
A button that always goes to lesson one
"Start the course" could be a pasted link to the first lesson. That works until you add a new first lesson or reorder the course. Finding it with Liquid means it's never out of date:
{%- assign ph_first = nil -%}
{%- for section in ph_course.ordered_published_lecture_sections -%}
{%- if ph_first == nil and section.lectures.size > 0 -%}
{%- assign ph_first = section.lectures.first -%}
{%- endif -%}
{%- endfor -%}
<a href="{% if ph_first %}/courses/{{ ph_course.id }}/lectures/{{ ph_first.id }}{% else %}#curriculum{% endif %}">
Start the course
</a>
The check for a section with lessons in it matters: an empty first section would otherwise leave the button going nowhere. If no lesson is found at all, it falls back to scrolling down to the curriculum.
A sales page for people who've already bought
A course drop knows two things about the person looking at it: whether they're enrolled, and how far through they are. That works through the loop too, so any page can use it. An enrolled student can see where they got to instead of the pitch they've already said yes to:
{% if ph_course.user_enrolled? %}
<p>You're {{ ph_course.percent_complete_for_user }}% of the way through.</p>
<a href="/courses/{{ ph_course.id }}">Carry on</a>
{% else %}
<a href="#enrol">Enrol now</a>
{% endif %}
It's the same idea as the signed-in menu in the custom navigation article, taken one step further: not just who you are, but what you've bought. The live box above does this if you're signed in.
Reusing it for another product
- Copy the six block files and give them the new course's name.
- In the hero, curriculum and call to action blocks, change
target_course_idto the new course's ID. - Rewrite the hero, outcomes and questions. Those are the only words you write.
- Paste the blocks into the new course's sales page, after the shared colours and the navigation.
The curriculum, counts, prices and buttons take care of themselves from then on.
What Teachable's Liquid gives you
Teachable's Liquid page lists objects and field names, but not which ones work, where, or what they return, and there's no one at Teachable to ask. Everything below was tested on this school with a throwaway page that prints each field. It's the list I wish I'd had, and it grows as testing turns up more.
Works, and isn't in the documentation
| Liquid | What it gives you |
|---|---|
course.ordered_published_lecture_sections | Published sections in order, with lesson durations |
lecture.duration | Video length in seconds (ordered list only) |
lecture.icon_v3 | The lesson's icon name: Video, Code... |
course.products | Every pricing plan, archived ones included |
course.author_bio.name, .bio, .profile_image_url | The course's author details |
page.title, page.friendly_url | The page you're on |
current_school.categories, .custom_links | Lists exist; fields still to test |
where and sort filters | Filter and order lists, including drops |
In the documentation, and works
| Liquid | Notes |
|---|---|
course.default_product.formatted_price | The default price, ready to show |
course.user_enrolled? | True or false, for whoever is looking |
course.percent_complete_for_user | A number, 0 to 100 |
course.page_title, .meta_description | From the course's search settings |
product.price | In cents: $111 is 11100 |
product.is_recurring | True for prices set to repeat, false for one-off prices |
current_user.name, .email, .role | Only the viewer's own details |
current_school.is_blog_enabled | Still true: Teachable's old built-in blog, switched on underneath |
Catches
- There's no
jsonfilter, so there's no shortcut to list every field. Printing a whole drop gives its type name, such asCourseDrop. - A wrong field name gives a blank, not an error.
course.price,course.is_freeandcourse.lowest_pricedon't exist, and look exactly like empty values. current_pagedoesn't exist. It'spage.course.course_questionscame back empty on a course with no questions set. Whether it works with questions is still to test.is_publishedchanged meaning. Teachable's docs say whether a course is listed is nowis_listed. Older Liquid that checksis_publishedmay quietly show the wrong courses.- Anything Liquid prints is in the page's HTML. Filter archived plans and hidden courses out before showing them, not with styles after.
Where it stops
- Checkout is Teachable's. The sales page can be anything you like, but the moment someone clicks to pay, they're on Teachable's checkout, which only takes your school's font and colours.
- None of this works in lessons. Liquid only runs on pages, which is why this is an article and not a lesson in the free course.
In short
- On a course's own sales page,
courseis there already. Anywhere else, loop throughpublicly_viewable_coursesto find it by ID. - Use
ordered_published_lecture_sections, neverlecture_sections: it's the one with durations. - For prices, filter to live plans first, then sort. Archived plans are what used to scramble the order.
- Enrolment and progress work on any page, so a sales page can greet people who've already bought.
- Teachable's Liquid documentation is incomplete, so test before you rely on anything.