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.

The catalog section, sharing the exact card design from /catalog

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).”

Methodology: three blocks explaining how the courses are different

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.

Instructor bio: real copy, not a lorem-ipsum placeholder

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.

The same page, switched to pt

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.

The Downtime Banner rendering above the nav

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.

The hero and nav, the actual first thing a prospective student sees

The relocated dev nav page, now instructor-only at /dev-nav

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.