//! The use-cases page at `/use-cases`, described. //! //! The second public document, and the first with content worth calling //! content: nine creator profiles, four platform promises, a call to action and //! three onward links. It replaces `templates/pages/use_cases.html`, //! `UseCasesTemplate` and `landing::use_cases_page`. //! //! `/team` proved the mount ([`super::Audience::Anyone`], `4239540d`); this one //! asks whether the vocabulary carries a marketing page. It does, and the two //! places it pushed back are worth the reader's time. //! //! # A card with a bullet list is a region, not a row //! //! `Row` refuses to hold a node, deliberately: "a row part may not carry an //! arbitrary node, which is the door through which a description becomes a //! templating language" (`layout::RowPart::Proportion`). Each use-case card has //! a four-to-five item feature list inside it, so a card is a //! [`RegionKind::Group`] labelled with its title, and the features are a //! [`Node::list`] of rows carrying nothing but a primary. //! //! The four "Everyone gets" cards have a name and a sentence and nothing else, //! so those *are* rows, and they are one `Node::list` rather than four regions. //! Same page, two shapes, and the difference is whether the card contains a //! structure or a sentence. //! //! # The grid is not described, and that is the point //! //! `use-case-grid` was `display: grid` with a column count. There is no grid //! region and there should not be: `RegionKind::Columns` is a kanban of peers //! that choose nothing about each other, which is a different claim. Nine //! sibling groups say what is true -- nine cards, equal, belonging to one //! section -- and how they sit is the design system's answer for every consumer //! rather than this page's CSS. Same call as `/team`'s single-card grid. //! //! # The prices are read per request, from the same place the calculator reads //! //! Every tier line interpolates [`crate::tier_prices::TierPrices`], which is //! `Billing`'s and is derived from `docs/business/assumptions.toml`. The screen //! reads it off `viewer.app` rather than taking a state of its own: a public //! document already has a per-request viewer, and a second copy of the prices //! is a second thing to keep in step with the calculator. use makeover_layout as layout; use quasi_router::screen::Row; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; use quasi_webview::Webview; use crate::tier_prices::TierPrices; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/use-cases"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "use-cases"; /// The nine profiles. const PROFILES: &str = "use-case-profiles"; /// What every tier includes. const UNIVERSAL: &str = "everyone-gets"; const MEASURE: layout::Measure = layout::Measure::Wide; /// One creator profile. /// /// `anchor` is the template's `id=`, kept because the marketing site links to /// these cards directly (`/use-cases#podcasters`) and a conversion that drops /// the id breaks somebody else's link rather than this page. struct Profile { anchor: &'static str, title: &'static str, who: &'static str, description: &'static str, features: &'static [&'static str], /// Which tier line the card ends with. See [`Priced`]. priced: Priced, } /// How a card states its price. /// /// The template wrote three shapes of tier line by hand, and one of them /// (Educators) names two tiers. Naming the shape rather than the string is what /// keeps every price on this page coming from `TierPrices` -- a formatted string /// in the table here would be a fourth place a price can go stale. #[derive(Clone, Copy)] enum Priced { Basic, SmallFiles, BigFiles, /// "Basic $X/mo or Small Files $Y/mo", which is Educators and only /// Educators: the work fits in either tier depending on what is uploaded. BasicOrSmallFiles, } impl Priced { /// The line the card ends with, built from the live prices. fn line(self, prices: &TierPrices) -> String { match self { Self::Basic => format!( "Basic ${}/mo · {}, {}/file", prices.basic_std, prices.basic_total, prices.basic_per_file ), Self::SmallFiles => format!( "Small Files ${}/mo · {}, {}/file", prices.small_files_std, prices.small_files_total, prices.small_files_per_file ), Self::BigFiles => format!( "Big Files ${}/mo · {}, {}/file", prices.big_files_std, prices.big_files_total, prices.big_files_per_file ), Self::BasicOrSmallFiles => format!( "Basic ${}/mo or Small Files ${}/mo", prices.basic_std, prices.small_files_std ), } } } /// The nine, in the order the template listed them. const PROFILE_LIST: &[Profile] = &[ Profile { anchor: "musicians", title: "Musicians", who: "Bands, solo artists, producers", description: "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.", features: &[ "MP3, FLAC, WAV, OGG, AAC, AIFF upload", "In-browser audio player with chapters", "Cover art and rich metadata", "RSS feed per project", "Pay-what-you-want pricing", ], priced: Priced::SmallFiles, }, Profile { anchor: "podcasters", title: "Podcasters", who: "Independent shows, networks", description: "Host and distribute your podcast with full RSS support for every major directory.", features: &[ "RSS for Apple, Spotify, Pocket Casts", "Chapter markers", "Subscriber-only feeds", "Broadcast emails to followers", ], priced: Priced::SmallFiles, }, Profile { anchor: "writers", title: "Writers", who: "Bloggers, newsletter writers, authors", description: "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.", features: &[ "Markdown editor with formatting", "Per-project blog", "RSS feed", "Scheduled publishing", "Broadcast emails", ], priced: Priced::Basic, }, Profile { anchor: "developers", title: "Software Developers", who: "Indie devs, tool makers, plugin authors", description: "Distribute software with versioned releases, license keys, and a built-in git browser.", features: &[ "Versioned releases with changelogs", "License keys with activation tracking", "Git source browser", "Promo codes and discounts", ], priced: Priced::SmallFiles, }, Profile { anchor: "sample-packs", title: "Sample Packs", who: "Sound designers, beat makers, foley artists", description: "Sell sample packs and sound effects with audio previews and versioned downloads.", features: &[ "Audio previews in-browser", "Versioned downloads", "Hierarchical tags for organization", "Pay-what-you-want pricing", "Cover art", ], priced: Priced::SmallFiles, }, Profile { anchor: "digital-art", title: "Digital Art & Photography", who: "Illustrators, photographers, font designers", description: "Sell digital art, photography, and fonts with versioned releases and subscription tiers.", features: &[ "Any file type supported", "Versioned releases", "Subscription tiers", "Cover images", ], priced: Priced::SmallFiles, }, Profile { anchor: "educators", title: "Educators", who: "Course creators, tutors, workshop leaders", description: "Publish courses and teaching materials with text content, downloads, and subscriptions.", features: &[ "Text content with Markdown", "Downloadable materials", "Subscription tiers", "Scheduled publishing", ], priced: Priced::BasicOrSmallFiles, }, Profile { anchor: "game-devs", title: "Game Developers", who: "Indie games, mods, TTRPG content", description: "Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.", features: &[ "Up to 20GB per file (increase on request)", "Versioned downloads with changelogs", "License keys with activation limits", "Promo codes", ], priced: Priced::BigFiles, }, Profile { anchor: "comic-creators", title: "Comic Creators", who: "Webcomic artists, graphic novelists", description: "Sell digital comics and graphic novels with project organization and scheduled publishing.", features: &[ "PDF, CBZ, and any file type", "Project organization", "Subscriptions", "Scheduled publishing", ], priced: Priced::SmallFiles, }, ]; /// What every tier includes, name and sentence, in the template's order. const UNIVERSAL_LIST: &[(&str, &str)] = &[ ( "0% platform fee", "Only ~3% payment processing. No percentage cuts, no revenue sharing.", ), ( "Full data export", "Download everything anytime. Projects, items, sales, contacts: all yours.", ), ( "No lock-in", "Month-to-month. Cancel anytime. Take your audience with you.", ), ( "Source available", "Read the codebase. Verify every claim about privacy and data handling.", ), ]; /// Where the page sends a reader who is convinced, and where it sends one who /// is not yet. const ONWARD: &[(&str, &str)] = &[ ("Pricing Calculator", "/pricing"), ("Browse as guest", "/discover"), ("Creator Guide", "/docs/getting-started"), ]; /// The page. Reads no database, only the prices already in memory. pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { use axum::extract::FromRef as _; let billing = crate::Billing::from_ref(&viewer.app); Ok(page_screen(&billing.tier_prices).into()) } /// The whole document: the title, the measure, the body. fn page_screen(prices: &TierPrices) -> Described { let mut profiles = Slot::new(PROFILES, RegionKind::Group); for profile in PROFILE_LIST { profiles = profiles.with(Node::Region(card(profile, prices))); } let universal = Node::list( UNIVERSAL_LIST .iter() .map(|(name, description)| Row::new(*name).secondary(*description)), ); let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) .with(Node::page("Use Cases")) .with(Node::text( "A flat monthly fee. 0% platform cut. Who it's built for:", )) .with(Node::section("Available now")) .with(Node::Region(profiles)) .with(Node::section("Everyone gets")) .with(Node::Region( Slot::new(UNIVERSAL, RegionKind::Group).with(universal), )) .with(Node::act( "Join the Alpha", Action::get("/join").navigating(), )); for (label, route) in ONWARD { page = page.with(Node::act(*label, Action::get(*route).navigating())); } Described::single("Use Cases - Makenotwork") .measured(MEASURE) .documented( Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"])), ) .summarised( "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \ developers and six more kinds of creator.", ) .with(page) } /// One profile card: who it is for, what they get, what it costs. /// /// The region is identified by the template's anchor so `/use-cases#podcasters` /// still lands on the right card. fn card(profile: &Profile, prices: &TierPrices) -> Slot { let features = Node::list(profile.features.iter().map(|feature| Row::new(*feature))); Slot::new(profile.anchor, RegionKind::Group) .label(profile.title) .with(Node::text(profile.who)) .with(Node::text(profile.description)) .with(features) .with(Node::text(profile.priced.line(prices))) } /// The document this screen is drawn in. Same shape as `/team`'s: the skip /// link and the site header, which reads the viewer's user through an `Option` /// because on this mount there may not be one. #[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 prices() -> TierPrices { TierPrices::default() } fn html() -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&prices())) } /// `2790e5c4`. The template wrote the measure on the body and /// `use-cases-page` on a container div; a described document has no /// container, so both land on the body. Same merge `/team` found. #[test] fn the_document_carries_the_classes_the_template_carried() { let screen = page_screen(&prices()); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page use-cases-page") ); let rendered = html(); assert!( rendered.contains("class=\"padded-page use-cases-page\""), "{rendered}" ); } /// Every card the template drew is still drawn, and still reachable by the /// anchor the marketing links use. #[test] fn every_profile_keeps_its_card_and_its_anchor() { let html = html(); assert_eq!(PROFILE_LIST.len(), 9, "the template drew nine"); for profile in PROFILE_LIST { assert!(html.contains(profile.anchor), "{} missing", profile.anchor); assert!(html.contains(profile.who), "{} missing", profile.who); for feature in profile.features { assert!( html.contains(feature), "{feature} missing from {}", profile.title ); } } } /// No price is written into this module. Each tier line is built from /// `TierPrices`, so a price change in `assumptions.toml` moves this page /// with it. #[test] fn every_tier_line_comes_from_the_live_prices() { let mut prices = prices(); prices.basic_std = 4321; prices.small_files_std = 5678; prices.big_files_std = 8765; let html = { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&prices)) }; assert!(html.contains("4321"), "the Basic price is not read: {html}"); assert!(html.contains("5678"), "the Small Files price is not read"); assert!(html.contains("8765"), "the Big Files price is not read"); } /// The educators card is the only one naming two tiers, and it is the case /// a formatted string in the table would have quietly flattened. #[test] fn the_educators_card_names_both_tiers_it_fits_in() { let mut prices = prices(); prices.basic_std = 4321; prices.small_files_std = 5678; let line = Priced::BasicOrSmallFiles.line(&prices); assert!(line.contains("4321") && line.contains("5678"), "{line}"); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }