Open a Course you had already bought and you got the page that sold it to you: the hero, the curriculum as a pitch, and a small panel with your Progress and a “Continue Learning” button that always led to the Course’s first Lesson, wherever you had stopped.
Same URL, different page
/catalog/:id now renders the Course overview for a Student with an Enrollment, however they got it (purchase or Invitation). Everyone else gets the sales page, unchanged: a logged-out visitor, a logged-in user without an Enrollment, and the Instructor.

The overview is read-only. The design-system prototype has a circle on each Lesson that marks and unmarks it, and the refinement dropped it: marking would bypass the server’s Check for Active Lessons (ADR 0014) and turn the page into a shortcut to the Certificate, and unmarking has no meaning in the domain. A Completion happens inside its Lesson or not at all.
One rule for “Continue”
The dashboard already had a correct “Continue”: the first Lesson without a Completion, in Course order. The Course page had its own wrong one. The fix was not to copy the dashboard’s line but to give the rule a name and one home, Enrollments.pending_lessons/2. The dashboard, the overview’s Continue and its “Next steps” all read from it.
flowchart LR
CH["Courses.list_chapters"] --> PL["Enrollments.pending_lessons/2"]
CO["Completions"] --> PL
PL -->|"first"| D["Dashboard · Continue"]
PL -->|"first"| C["Overview · Continue
+ that Chapter is current"]
PL -->|"first three"| N["Overview · Next steps"]
CH --> OV["Enrollments.Overview.build/2
(pure)"]
CO --> OV
PL --> OV
OV --> V["CatalogLive.Overview
(renders only)"]
Everything else on the page (stats, Chapter states, the Pendentes list, the per-type summary) is computed by Forgia.Enrollments.Overview, a pure module with its own unit tests. The LiveView only lays it out.
The filter is the one time the server opens a Chapter
The Chapter cards are the same collapsible component the Lesson chrome got in the previous post, at full size, with a Progress bar. Open/closed belongs to the browser, so the server only says which card starts open.
“Pendentes” needed the server to say more. Switching to it hides completed Lessons and finished Chapters and opens every Chapter with something pending:

The house rule says the server must not render a class the client will want to remove. The way around it is a counter. The filter switch bumps ov_generation, which is part of every card’s DOM id. New ids mean new elements, so fresh hooks mount and read their starting state from the filter. From there, open/close is the browser’s again. The server never renders is-open at all.
A finished Course swaps “Continue” for “View Certificate”, keeps the list navigable for review, and under Pendentes says so:

Purchase, then the page changes under you
Paddle confirms a payment in the browser, but the Enrollment is created later by a webhook. The page already polled for it and flipped its purchase panel to “Enrolled”. Now the same flip replaces the whole page with the overview, with no reload. That made the sales page’s “enrolled” panel unreachable, so it was deleted rather than left behind.
What the prototype promised that the product doesn’t
Four parts of the prototype were left out on purpose, and none of them is a gap:
- “de conteúdo restante”: only Video Lessons have a Duration, so a total would count video alone and understate the rest.
- “Acesso vitalício”: a commercial promise the product does not make anywhere.
- “Seu instrutor”: there is no Instructor profile in the model.
- “Referências e materiais”: split out into its own item, after Course Attachments.
Each of these appeared in the prototype as if it were a fact about the domain. The cost showed up in refinement, where every one of them had to be taken apart. A cheaper way: have a prototype mark each piece of data with where it would come from (an existing field, a new one, or nothing yet). Then the parts with no source show up in the design review, before anyone has to argue them out of a Card.