Until today, visiting learn.mariomelo.com got you a debug page: two boxes labelled “Trainer” and “Student”, each a pile of raw route links. Fine for a team that already knows the product — useless for the prospective student the whole platform exists to convince. This item gives / to that visitor: a nav, a hero, three blocks explaining the methodology, the instructor’s real bio, and the live course catalog, all behind the routing and locale infrastructure the rest of the app already runs on.
Where the old page went
The dev nav page didn’t disappear — it moved. It’s still exactly what it was, just no longer squatting on /:
flowchart LR
A["GET /"] -->|before| B[PageController#home
dev nav page]
A -->|after| C[LandingLive
public Landing Page]
D["GET /dev-nav
(admin only)"] --> E[DevNavController#index
same dev nav page]
ForgiaWeb.PageController became ForgiaWeb.DevNavController, and its route moved into the same :require_admin scope as the Instructor Studio. An anonymous visitor or a Student hitting /dev-nav now gets redirected to /catalog, same as any other Studio route — nothing new to build there, just falling in line with an existing pattern.
One catalog card, not two
The Confirmation criteria are specific: the Landing Page’s catalog section has to show exactly what /catalog already shows — same thumbnail-or-placeholder, same Free/Free-preview badges, same Review aggregate, same Paddle-resolved price. Rebuilding that card a second time would mean two markups that inevitably drift, so it isn’t rebuilt — it’s extracted.
ForgiaWeb.CatalogComponents.course_grid/1 now owns the grid, the empty state, and the card, and both CatalogLive.Index and the new LandingLive render the same component:
<.course_grid
courses={@courses}
aggregates={@aggregates}
free_preview_ids={@free_preview_ids}
paddle_client_token={@paddle_client_token}
paddle_environment={@paddle_environment}
empty_message={gettext("Courses coming soon")}
/>
The empty_message is the one thing that’s allowed to differ — the catalog page says “No published courses yet.”, the Landing Page says “Courses coming soon” — everything else, including the DOM structure the PaddlePrices hook queries, is shared.

Why this had to be a LiveView
Conversation.md left this genuinely open: should the Landing Page be a LiveView, or a plain controller page with a LiveComponent for the catalog grid? The answer turned out not to be a matter of taste. The paid-price display doesn’t store a number locally (ADR-0012) — it resolves client-side through Paddle.PricePreview(), wired through a phx-hook. Phoenix hooks only mount inside a connected LiveSocket. A controller-rendered page has no socket, so the hook — and the price — simply never runs.
That forced the decision: LandingLive is a full LiveView, exactly like CatalogLive.Index, which means the site’s highest-traffic, least-session-warranted page now opens a socket for every anonymous visit. That trade-off, and what to do about it if / traffic ever makes it matter, is written down in ADR-0021 (docs/adr/0021-landing-page-is-a-liveview.md).
sequenceDiagram
participant V as Anonymous visitor
participant LV as LandingLive
participant DB as Courses/Reviews
participant JS as PaddlePrices hook
participant P as Paddle.js
V->>LV: GET / (LiveSocket connects)
LV->>DB: list_public_published_courses()
LV->>DB: aggregates_for_courses(ids)
LV-->>V: render (paid cards show a shimmer, no amount)
JS->>P: PricePreview(batched price ids)
P-->>JS: localized totals
JS-->>V: swap shimmer for amount, in place
A naming collision worth avoiding
The Card and the design template both call the three methodology blocks — puzzles, interactive exercises, real-world cases — “highlights.” CONTEXT.md already defines Highlights as something else entirely: a Course’s own “what you’ll learn” bullets, authored per-Course by the instructor. Reusing the word for the Landing Page’s static marketing copy would have made “Highlights” mean two unrelated things depending on which screen you were reading about.
So the Landing Page section is called Methodology in the code, the CSS (mm-methodology-grid, mm-methodology-item), and this post. It’s presentational copy, not a reusable domain concept — one instructor, one bio, one set of three blocks — so it isn’t in CONTEXT.md at all. The rename cost nothing and the alternative would have cost a glossary entry explaining “Highlights (the Course kind)” versus “Highlights (the Landing Page kind).”

Instructor bio: hardcoded on purpose
CONTEXT.md’s definition of Profile already flags bio as “the future home for… bio” — future, not now. Forgia has exactly one Instructor, and the bio copy was already written by hand for the design template. Modeling a Profile.bio field for a value that will only ever have one row would be exactly the kind of premature abstraction the rest of this codebase avoids. The name, title, bio paragraph, and stats are gettext-wrapped strings in LandingLive’s template — translated, but not database-backed.

The instructor photo itself stays a placeholder box — the user hasn’t supplied the image asset yet, and shipping the design template’s checkerboard-and-label pattern was the explicit, agreed-on stand-in until it lands.
Locale, for free
Every other screen resolves its Locale through ForgiaWeb.Plugs.Locale — a logged-in Profile’s preference, then a cookie, then Accept-Language, then a default. LandingLive adds zero locale logic of its own: on_mount ForgiaWeb.Plugs.Locale is already global to every live_view in forgia_web.ex, so the moment the template calls gettext/1 it’s correct. 27 new strings went through mix gettext.extract --merge, with Portuguese filled in by hand in priv/gettext/pt/LC_MESSAGES/default.po — English needs no separate file, since the gettext() call is the English source string.

Still there: the Downtime Banner
CONTEXT.md names home explicitly as one of the surfaces the Downtime Banner renders on, alongside /catalog, the Dashboard, and the Studio. Since / changed what it is without changing that it’s still “home,” the banner comes along for free — <.downtime_banner banner={@downtime_banner} /> at the top of LandingLive’s template, same as everywhere else it appears.

What the browser confirmed
Seeded a real published Course — thumbnail, description, a Paddle sandbox Price, one completed Enrollment with a five-star Review — and drove it end to end: the hero renders and its CTA is a plain #catalog anchor (no push_navigate, no JS needed for the scroll); the catalog card shows the Paddle-resolved R$300,00 and the 5.0 · 1 review aggregate exactly as /catalog would render them; an Instructor reaches /dev-nav while an anonymous visitor hitting the same URL bounces to /catalog; and the pt switch instantly re-renders every string, hero to footer.


Two states — the zero-Course empty message and a no-thumbnail/no-Review card — are covered by landing_live_test.exs instead of a screenshot: the dev database already has three dozen seeded Courses, and clearing them out just to catch one screenshot would have meant destructively mutating shared dev state for a case the automated suite already proves.
What’s deferred
Out of scope by the Card and Conversation, not forgotten: the testimonials section from the design template, and the footer’s legal links (Terms/Privacy/Contact). Both are their own backlog items when they come up.