//! 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_router::screen::{Figure, Row}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; 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"; /// What a member gets. /// /// **Keep this list and the Fan+ card on the landing page identical, and keep /// both to what the code grants.** Audited 2026-08-05, carried over from the /// template's comment because it is a rule rather than a note: a benefit listed /// here and not granted by the code is a promise nobody implemented. const BENEFITS: &[(&str, &str)] = &[ ( "$5 monthly credit", "A promo code delivered by email each billing cycle, usable toward any purchase on the platform", ), ("+ badge", "Displayed next to your name on your forum posts"), ( "Forum signatures", "A signature block rendered under everything you post", ), ("Image embeds", "Post images in forum threads"), ]; /// What this request knows about the reader's membership. 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 page. 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()) } /// 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, }) } /// The whole document: the title, the measure, the body. fn page_screen(standing: &Standing, just_subscribed: bool) -> Described { let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Fan+")); if just_subscribed { page = page.with(Node::banner( layout::Tone::Success, "You're now a Fan+ member. Welcome.", )); } page = match standing { Standing::Member { period_end } => membership(page, period_end.as_deref()), Standing::Unsubscribed => { pitch(page).with(Node::act("Join Fan+", Action::post(SUBSCRIBE).awaiting())) } Standing::Visitor => { // Fan+ needs an account, so a visitor gets both paths: the one for // people who already have one and the one for people who do not. // Without the second this page is a dead end for exactly the // visitor it is written for (`loose-wire g1-23`, and // `fan_plus_page_renders_for_anonymous` is the seal). // // One sentence with two inline links, not two buttons. The sentence // says which link is for whom and a pair of buttons does not, and // that distinction is the whole point of the finding above. It is // prose, so it is `Node::rich`, on `/policy`'s rule. pitch(page).with(super::own_prose( "[Create an account](/join) to join, or [log in](/login) if you already have one.", )) } }; Described::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.", ) .with(page) } /// What a member is shown: the state of the thing they are paying for. fn membership(page: Slot, period_end: Option<&str>) -> Slot { let mut page = page.with(Node::text("Your Fan+ membership is active.")); if let Some(end) = period_end { page = page.with(Node::text(format!("Current period ends: {end}"))); } page.with(Node::text( "You'll receive a $5 credit code each billing cycle via email.", )) } /// 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. fn pitch(page: Slot) -> Slot { page.with(Node::text( "Support the platform and get something back every month.", )) .with(Node::section("What you get")) .with(Node::list( BENEFITS .iter() .map(|(name, detail)| Row::new(*name).secondary(*detail)), )) .with(Node::stats([Figure::new(PRICE, "per month")])) .with(Node::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. #[test] fn the_four_benefits_are_all_stated() { let html = html(&Standing::Unsubscribed, false); assert_eq!(BENEFITS.len(), 4); for (name, _) in BENEFITS { assert!(html.contains(name), "{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}" ); } } } }