//! The Fan+ membership page at `/fan-plus`, described. //! //! The fourth public document, and the first that branches on the viewer for //! something other than the site header. It replaces //! `templates/pages/fan_plus.html`, `FanPlusTemplate` and //! `landing::fan_plus_page`. //! //! # Three readers, one address, which is what the optional user bought //! //! `/team`, `/use-cases` and `/policy` read the same to everybody and used //! [`super::Audience::Anyone`] only so the header could greet a signed-in //! reader. This page uses it for the page: //! //! ```text //! a visitor the pitch, and both ways to get an account //! a reader, unsubscribed the pitch, and the control that subscribes //! a reader, subscribed their membership's state, and nothing to buy //! ``` //! //! The visitor branch is the one worth naming. Fan+ needs an account, so a //! visitor is offered `/join` and `/login` rather than a control they cannot //! use; the template's own comment says why ("without the second this page is a //! dead end for exactly the visitor it is written for") and it is carried here //! because it is a product decision rather than markup. //! //! # The database is read only for a reader who might have a subscription //! //! A visitor's request makes no query at all, which is the shipped behaviour //! and worth keeping: this is a marketing page, and the busiest thing about it //! is people who have not signed up. //! //! # One thing lost, and it is 12 sites rather than this one //! //! `data-loading-text="Redirecting to Stripe..."`. [`Action::awaiting`] says //! that a wait is happening and the design system draws it, but nothing carries //! the sentence. Measured before assuming it was this page's problem: 12 of the //! server's 16 `data-loading-text` sites say "Redirecting to Stripe..." or //! "Opening Stripe...", so the attribute is not a general facility for wait //! wording -- it is one idea, "this control hands you off to the payment //! provider", spelled twelve times. //! //! `Action::leaving` is the near miss and does not fit: it is `Method::Get`, //! and every one of the twelve is a POST to our own route that answers with a //! redirect. Filed against quasicoherent rather than worked around with a //! hand-written attribute inside a described form. //! //! # The visitor's sentence is the page's own, and says so //! //! [`super::own_prose`] rather than `Node::rich`. The two links in it point at //! `/join` and `/login`, our own pages, and an untrusted source would have them //! carrying `nofollow` -- which is what shipped until quasi grew a trust axis //! separate from its richness one (quasicoherent `24a3b1df`, quasi 0.94). use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::Figure; use quasi_router::{Document, Request, Response, RouteError}; use quasi_webview::Webview; use crate::db; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/fan-plus"; /// Where the subscription is actually started. Not this screen's route: it is /// the Stripe checkout handoff, which already exists and already carries the /// CSRF posture it needs. const SUBSCRIBE: &str = "/stripe/fan-plus"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "fan-plus"; const MEASURE: layout::Measure = layout::Measure::Wide; /// What the membership costs, in whole dollars per month. const PRICE: &str = "$8"; /// The region a member's own state is drawn in. const MEMBERSHIP: &str = "fan-plus-membership"; /// The region the pitch is drawn in. const PITCH: &str = "fan-plus-pitch"; /// What this request knows about the reader's membership. /// /// `pub(crate)` so `quasi::residuals` can name it: the residual checks live /// beside the roster rather than in each screen, and a screen that branches has /// to be filled under every branch to be checked at all. pub(crate) enum Standing { /// Nobody is signed in. The page is a pitch plus a way to get an account. Visitor, /// Signed in, not a member. Unsubscribed, /// Signed in and paying, with the date the current period ends when Stripe /// has told us one. Member { period_end: Option }, } /// The three questions the page asks about the reader, spelled once. /// /// Predicates rather than a dispatch: `Member` carries a value, and the form /// has no binding pattern to reach it through an arm. impl Standing { /// Signed in and paying. const fn is_member(&self) -> bool { matches!(self, Self::Member { .. }) } /// Signed in, not paying: the one reader who can be sold to directly. const fn is_unsubscribed(&self) -> bool { matches!(self, Self::Unsubscribed) } /// Nobody is signed in. const fn is_visitor(&self) -> bool { matches!(self, Self::Visitor) } /// When the current billing period ends, or nothing. /// /// Empty rather than `None`, so the description asks one question and reads /// one answer instead of matching an `Option` it cannot spell a pattern for. fn period_end(&self) -> &str { match self { Self::Member { period_end: Some(end), } => end, _ => "", } } } /// The page. /// /// Kept beside the residual mount rather than replaced by it, and it is what the /// residual is checked against: `quasi::residuals` asserts that filling the /// compiled one gives back exactly what building and rendering this gives, on /// every branch. A screen with no second way to produce its markup has nothing /// to check the first one with. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out of the request rather than borrowed: the signature is quasi's, // so the request arrives owned. let carried = request.carried; let just_subscribed = carried .get("subscribed") .is_some_and(|value| value.trim() == "true"); let standing = standing(viewer)?; Ok(page_screen(&standing, just_subscribed).into()) } /// The two things this page reads, for the mount that serves it from a residual. /// /// One read, stating the document and deciding which branches are filled. /// /// A failure to read the membership refuses, the way it does on every other /// screen. Answering `Visitor` instead would show a paying member the page that /// asks them to subscribe, and a page that quietly tells a reader the opposite /// of the truth about their own money is worse than one that does not load. pub(crate) fn reading( viewer: &super::Viewer, carried: &super::Carried, ) -> Result<(Standing, bool), RouteError> { Ok((standing(viewer)?, carried.says("subscribed", "true"))) } /// Read the reader's membership, if there is a reader. fn standing(viewer: &super::Viewer) -> Result { let Some(user) = viewer.user.as_ref() else { return Ok(Standing::Visitor); }; let subscription = viewer .block_on(db::fan_plus::get_fan_plus_by_user(&viewer.app.db, user.id)) .map_err(|_| RouteError::internal("your membership could not be read"))?; Ok(match subscription { Some(sub) if sub.status == "active" => Standing::Member { period_end: sub .current_period_end .map(|end| end.format("%B %-d, %Y").to_string()), }, _ => Standing::Unsubscribed, }) } declare! { /// The whole document: the title, the measure, the body. /// /// Three readers at one address, which is what the optional user bought: /// a visitor gets the pitch and both ways to get an account, a signed-in /// reader gets the pitch and the control that subscribes, and a member gets /// their membership's state and nothing to buy. pub(crate) shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen; screen single "Fan+ - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])); summarised "Support the platform and get $5 of credit back every month, plus forum badges, \ signatures and image embeds."; include page_region(standing, just_subscribed); } } declare! { /// The page's one region, split out so it can be staged. /// /// **The first screen on the seam whose residual branches.** `/policy` and /// `/team` fold to one literal and `/use-cases` is literals and holes; this /// page asks four questions about the reader, so its residual carries the /// markup of every answer and the filler walks the arms the request picks. /// That is the point rather than a cost: the three readers see three /// documents, and all three are compiled. #[staged] pub(crate) shape page_region(standing: &Standing, just_subscribed: bool) -> Slot; region PAGE_REGION as Pane { page "Fan+"; banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed; include membership(standing) when standing.is_member(); include pitch() unless standing.is_member(); act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed(); for visitor in copy "content/fan-plus.toml" as visitor { include super::own_prose(visitor.body) when standing.is_visitor(); } } } declare! { /// What a member is shown: the state of the thing they are paying for. /// /// `#[staged]` because [`page_region`] includes it and hands it the /// standing, which is a value the request brings. A staged `include` whose /// arguments are not all literal opens a scope and calls the callee's /// filler, so the callee has to have one; without the flag the call does /// not resolve and rustc names `membership_fill`. The other direction is /// [`pitch`], which takes nothing and is `#[constant]` instead. #[staged] shape membership(standing: &Standing) -> Slot; region MEMBERSHIP as Group { text "Your Fan+ membership is active."; text "Current period ends: {standing.period_end()}" unless standing.period_end().is_empty(); text "You'll receive a $5 credit code each billing cycle via email."; } } declare! { /// What somebody who is not a member is shown, whether or not they have an /// account. The two branches differ only in what they are offered /// afterwards. /// /// `#[constant]`: it reads nothing the request brings. `PRICE` is a `const` /// and a staged path is kept rather than made a hole, so the figure is /// evaluated once while the residual is derived. The benefits are copy read /// at macro time, so the loop is unrolled before any AST exists. #[constant] shape pitch() -> Slot; region PITCH as Group { text "Support the platform and get something back every month."; section "What you get"; list { for benefit in copy "content/fan-plus.toml" as benefits { row benefit.name { secondary benefit.detail; } } } stats [Figure::new(PRICE, "per month")]; text "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \ platform's development and operations, while getting real value back each month."; } } /// The document this screen is drawn in. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } #[cfg(test)] mod tests { use super::*; fn html(standing: &Standing, just_subscribed: bool) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(standing, just_subscribed)) } /// `2790e5c4`. Both classes were on the body already, so this is a copy. #[test] fn the_document_carries_the_classes_the_template_carried() { let screen = page_screen(&Standing::Visitor, false); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page fan-plus-page") ); let rendered = html(&Standing::Visitor, false); assert!( rendered.contains("class=\"padded-page fan-plus-page\""), "{rendered}" ); } /// The visitor branch, which is the one the template's own comment exists /// to protect: no subscribe control, and both ways to get an account. #[test] fn a_visitor_is_offered_an_account_rather_than_a_dead_end() { let html = html(&Standing::Visitor, false); // Asserted on each link's own copy rather than on the bare hrefs: the // site header carries `/join` and `/login` on every page, so an href // alone would pass whatever this block said. // // Each link's own copy, and neither of them nofollowed. The `nofollow` // half is the seal on this being `own_prose`: an untrusted source is // hardened by the renderer and both anchors would carry it. // // `rel="noopener noreferrer"` does survive, from ammonia's default, and // is left alone: it suppresses the referrer and the opener handle, not // the crawl, so it costs nothing an internal link needs. assert!(html.contains(r#"href="/join""#), "{html}"); assert!(html.contains(">Create an account to join"), "{html}"); assert!(html.contains(r#"href="/login""#), "{html}"); assert!( html.contains(">log in if you already have one"), "{html}" ); assert!( !html.contains("nofollow"), "the page nofollowed its own links: {html}" ); assert!( !html.contains(SUBSCRIBE), "a visitor cannot subscribe, so the control must not be drawn: {html}" ); } /// A signed-in reader who is not a member gets the pitch and the control. #[test] fn a_reader_who_is_not_a_member_is_offered_the_subscription() { let html = html(&Standing::Unsubscribed, false); assert!(html.contains(SUBSCRIBE), "{html}"); assert!(html.contains("Join Fan+"), "{html}"); assert!( !html.contains(r#"href="/join""#), "somebody signed in does not need an account: {html}" ); } /// A member is shown their membership and nothing to buy. #[test] fn a_member_is_shown_their_period_and_offered_nothing() { let html = html( &Standing::Member { period_end: Some("March 4, 2027".into()), }, false, ); assert!(html.contains("March 4, 2027"), "{html}"); assert!(html.contains("membership is active"), "{html}"); assert!( !html.contains(SUBSCRIBE), "a member must not be sold to again: {html}" ); assert!( !html.contains("What you get"), "the pitch is for people who have not bought: {html}" ); } /// Stripe does not always give a period end, and the row is dropped rather /// than rendered empty. #[test] fn a_member_with_no_known_period_end_is_told_the_rest_anyway() { let html = html(&Standing::Member { period_end: None }, false); assert!(html.contains("membership is active"), "{html}"); assert!(!html.contains("Current period ends"), "{html}"); } /// `?subscribed=true` is what Stripe sends the reader back with. /// /// Matched on the half of the sentence with no apostrophe in it: the /// description layer escapes one to `'`, so the literal from the source /// never appears in the markup. #[test] fn the_welcome_banner_shows_only_on_the_way_back_from_checkout() { assert!(html(&Standing::Member { period_end: None }, true).contains("now a Fan+ member")); assert!(!html(&Standing::Member { period_end: None }, false).contains("now a Fan+ member")); } /// The benefits are what the code grants, and the landing page's Fan+ card /// says the same four. Audited 2026-08-05; this keeps the count honest. /// /// Reads `content/fan-plus.toml` the way the macro reads it, which is /// `policy`'s rule: the file is asked what the page should say rather than /// a second copy of it being held here. The count stays asserted, because /// the audit is about the number as much as the names. #[test] fn the_four_benefits_are_all_stated() { let html = html(&Standing::Unsubscribed, false); let copy: toml::Table = include_str!("../../content/fan-plus.toml") .parse() .expect("the fan-plus copy is TOML"); let benefits = copy["benefits"].as_array().expect("a list of benefits"); assert_eq!(benefits.len(), 4); for benefit in benefits { let name = benefit["name"].as_str().expect("a name"); let escaped = crate::helpers::escape_html(name); assert!(html.contains(&escaped), "{name} missing"); } } /// `736f45a5`: the wait on the Stripe handoff is said by the description. /// The template spelled it `data-loading-text="Redirecting to Stripe..."` /// on the subscribe button, so the branch that draws that control asserts /// the word replacing it, and every branch asserts the four spellings are /// absent. #[test] fn the_subscribe_control_spells_no_spinner() { assert!( html(&Standing::Unsubscribed, false).contains("data-awaiting="), "{}", html(&Standing::Unsubscribed, false) ); for rendered in [ html(&Standing::Visitor, false), html(&Standing::Unsubscribed, false), html(&Standing::Member { period_end: None }, false), html(&Standing::Unsubscribed, true), ] { for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!( !rendered.contains(spelling), "{spelling} survives in {rendered}" ); } } } }