//! The creator-application page at `/creators`, described. //! //! The fifth public document. It replaces `templates/pages/creators.html`, //! `CreatorsTemplate` and `pages::creators_page`. //! //! # The tier table is a table, and it is the first described one on a public page //! //! Four tiers by four columns, and every cell in two of those columns comes //! from [`TierPrices`](crate::tier_prices::TierPrices). [`Table`] says it, the //! same builder `/feed` uses for its item list, so the widths and the //! narrow-viewport behaviour are the design system's rather than //! `.wave-table`'s. //! //! Where `/use-cases` said the same prices as prose inside nine cards, this //! says them as a grid, which is what the shipped page did too. Both read the //! one `TierPrices`, so the two pages cannot disagree about what Basic costs. //! //! # Three readers again, and the third one is new //! //! [`super::Audience::Anyone`] carries a fourth kind of branch here. `/fan-plus` //! split on whether the reader had bought; this splits on what the reader is //! allowed to do: //! //! ```text //! a visitor Join, and Login //! a reader Apply, from the dashboard //! a creator nothing to apply for; go to the dashboard //! ``` //! //! `can_create_projects` is the flag, read off the session user the factory //! already resolved, so the branch costs no query. //! //! # The count is live and the page says so //! //! `total_creators` is read at request time. It is the one number on this page //! that is not a price, and the disclosure is the point: a person deciding //! whether to apply is told how many creators are actually here. 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; use crate::tier_prices::TierPrices; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/creators"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "creators"; const MEASURE: layout::Measure = layout::Measure::Wide; /// What one tier's monthly fee says, by the name the copy gives it. /// /// The tier table was a `const TIERS: &[Tier]` here, whose rows carried two /// `fn(&TierPrices)` pointers. A `const` is a path, so a loop over it opens a /// scope per request and the four rows were rebuilt on every load; read out of /// `content/creators.toml` instead they are unrolled at macro time and fold /// into the residual's literals, with only the two figures left as holes. /// /// What it costs is exhaustiveness, which is `/use-cases`' trade exactly: a /// proc macro cannot evaluate a path, so the file names a tier as a `&str` and /// this matches it. [`tests::every_priced_name_in_the_copy_is_one_of_the_four`] /// is what buys it back. /// /// # Panics /// /// On a name this does not know, which is a content file naming a tier that /// does not exist. The test above makes that a test failure rather than a page /// that renders a blank column. fn tier_price(priced: &str, prices: &TierPrices) -> String { let monthly = match priced { "basic" => prices.basic_std, "small-files" => prices.small_files_std, "big-files" => prices.big_files_std, "everything" => prices.everything_std, other => panic!("content/creators.toml names a tier that does not exist: {other}"), }; format!("${monthly}") } /// The storage envelope one tier buys. See [`tier_price`]. /// /// # Panics /// /// On a name this does not know, for the same reason. fn tier_storage(priced: &str, prices: &TierPrices) -> String { match priced { "basic" => prices.basic_total.clone(), "small-files" => prices.small_files_total.clone(), "big-files" => prices.big_files_total.clone(), "everything" => prices.everything_total.clone(), other => panic!("content/creators.toml names a tier that does not exist: {other}"), } } /// Every name the two above answer to, which is what the copy is held to. #[cfg(test)] const TIER_NAMES: &[&str] = &["basic", "small-files", "big-files", "everything"]; /// What this request knows about the reader's standing. pub(crate) enum Standing { /// Nobody signed in. Visitor, /// Signed in, not yet a creator. Reader, /// Already has creator access. Creator, } impl Standing { /// Nobody signed in. /// /// Three predicates rather than a pattern, because the form reaches a value /// through a name and not through a match arm, and each of the three offers /// a different number of things afterwards. const fn is_visitor(&self) -> bool { matches!(self, Self::Visitor) } /// Signed in, not yet a creator. const fn is_reader(&self) -> bool { matches!(self, Self::Reader) } /// Already has creator access. const fn is_creator(&self) -> bool { matches!(self, Self::Creator) } } /// The page. /// The three things this page reads, for the mount that serves it from a /// residual. /// /// One read, stating the document and filling the holes. pub(crate) fn reading(viewer: &super::Viewer) -> Result<(Standing, i64, TierPrices), RouteError> { use axum::extract::FromRef as _; let total_creators = viewer .block_on(db::waitlist::count_active_creators(&viewer.app.db)) .map_err(|_| RouteError::internal("the creator count could not be read"))?; let standing = match viewer.user.as_ref() { None => Standing::Visitor, Some(user) if user.can_create_projects => Standing::Creator, Some(_) => Standing::Reader, }; let billing = crate::Billing::from_ref(&viewer.app); Ok((standing, total_creators, billing.tier_prices)) } pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { let (standing, total_creators, prices) = reading(viewer)?; Ok(page_screen(&standing, total_creators, &prices).into()) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen( standing: &Standing, total_creators: i64, prices: &TierPrices, ) -> Screen; screen single "Creators - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])); summarised "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \ tiers that pick a file-size envelope rather than a feature set."; include page_region(standing, total_creators, prices); } } declare! { /// The page's one region, split out so it can be staged. /// /// Almost all of it is prose this repository wrote, so almost all of it /// folds into one literal. What is left varying is the creator count, the /// four tier rows' two figures each, and which of the three calls to action /// this reader is shown. #[staged] pub(crate) shape page_region( standing: &Standing, total_creators: i64, prices: &TierPrices, ) -> Slot; region PAGE_REGION as Pane { page "Become a Creator"; text "Anyone can sign up to browse and buy. To create projects and sell your work, \ apply for creator access. Most applications are approved within a few days. \ Makenotwork is in private alpha; we're approving applications one cohort at a \ time."; section "How It Works"; include super::own_prose( "1. **Sign up** and verify your email\n\ 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\ 3. **Get approved**: we review applications individually, usually within a few days\n\ \n\ We review applications to make sure applicants are here to share and sell creative \ work. If you make something and want to sell it, you'll likely get in. Link to your \ existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\ \n\ **Important:** You sell in the currency your Stripe account settles in, and \ receiving payouts requires a [Stripe](https://stripe.com/global) account in a \ supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \ NZD or EUR**. Check both with Stripe before applying." ); stats [Figure::new(total_creators.to_string(), "Active Creators")]; section "Pricing"; text "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments \ is the payment processor's fee (~3%)."; include tier_table(prices); include super::own_prose( "Every tier is the complete platform: `/u/username` profile, project and item pages, \ project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \ analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \ not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \ NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \ supported country. [Full tier details](/docs/tiers) | \ [Pricing models](/docs/pricing)" ); include super::own_prose( "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \ you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \ up." ); section "Who Runs This"; include super::own_prose( "Makenotwork is built and operated by one person. No investors, no board, no outside \ pressure. Decisions are fast and aligned with creators, but there's no large team \ behind the scenes. Read the full picture in our \ [continuity guarantee](/docs/guarantees#continuity) and \ [platform economics](/docs/economics)." ); // The one part of the page that differs by who is asking, spread // where it was appended. `feeds` does the same with its body. include each call_to_action(standing); } } declare! { /// The four tiers, priced from the live figures. /// /// Four columns and four cells, written together, with no branch between /// them: every tier is a full row, so position is checkable by eye here and /// naming the columns would be ceremony. #[staged] shape tier_table(prices: &TierPrices) -> Node; table { column "Tier" { width Content; priority Essential; } column "Monthly" { width Content; } column "Best For" { width Fill; } column "Storage" { width Content; } for tier in copy "content/creators.toml" as tiers { cells { cell tier.name; cell tier_price(tier.priced, prices); cell tier.best_for; cell tier_storage(tier.priced, prices); } } } } declare! { /// What the page asks of this reader, which is the only thing on it that /// differs by who is asking. /// /// A panel rather than a `Slot` taken and handed back: each standing offers /// a different number of things -- a visitor two controls, the other two one /// each -- so the answer is a run of members and not one node. The three /// guards are exhaustive and disjoint by construction. #[staged] shape call_to_action(standing: &Standing) -> Vec; text "You have creator access." when standing.is_creator(); act "Go to Dashboard" to get "/dashboard" navigating when standing.is_creator(); text "Ready to create?" when standing.is_reader(); act "Apply from Dashboard" to get "/dashboard?tab=settings§ion=creator" navigating when standing.is_reader(); text "Join to get started." when standing.is_visitor(); act "Join" to get "/join" navigating when standing.is_visitor(); act "Login" to get "/login" navigating when standing.is_visitor(); } /// 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) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(standing, 7, &TierPrices::default())) } /// `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, 0, &TierPrices::default()); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page creators-page") ); let rendered = html(&Standing::Visitor); assert!( rendered.contains("class=\"padded-page creators-page\""), "{rendered}" ); } /// Every tier the table listed is still listed, and its price is read /// rather than written. #[test] fn every_tier_is_priced_from_the_live_figures() { let prices = TierPrices { basic_std: 4321, small_files_std: 5678, big_files_std: 8765, everything_std: 9876, ..TierPrices::default() }; let html = { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&Standing::Visitor, 0, &prices)) }; let tiers = copy_tiers(); assert_eq!(tiers.len(), 4); for tier in &tiers { for said in ["name", "best_for"] { let words = tier[said].as_str().expect("a string"); assert!(html.contains(words), "{words} missing"); } } for price in ["4321", "5678", "8765", "9876"] { assert!(html.contains(price), "{price} is not read from TierPrices"); } } /// The tier rows, read the way the macro reads them. fn copy_tiers() -> Vec { let copy: toml::Table = include_str!("../../content/creators.toml") .parse() .expect("the creators copy is TOML"); copy["tiers"] .as_array() .expect("a list of tiers") .iter() .map(|tier| tier.as_table().expect("a table").clone()) .collect() } /// Every tier the copy names is one the two pricing functions answer to. /// /// What buys back the exhaustiveness the copy move cost. `tier_price` /// panics on a name it does not know, and this is what makes that a test /// failure rather than a page that renders a blank column. #[test] fn every_priced_name_in_the_copy_is_one_of_the_four() { for tier in copy_tiers() { let priced = tier["priced"].as_str().expect("a string"); assert!( TIER_NAMES.contains(&priced), "{priced} is not a tier the price functions answer to", ); } } /// The live disclosure: how many creators are actually here. #[test] fn the_active_creator_count_is_shown() { assert!(html(&Standing::Visitor).contains('7')); assert!(html(&Standing::Visitor).contains("Active Creators")); } /// A visitor is offered an account, not an application they cannot file. #[test] fn a_visitor_is_offered_both_ways_in() { let html = html(&Standing::Visitor); assert!(html.contains(r#"href="/join""#), "{html}"); assert!(html.contains(r#"href="/login""#), "{html}"); assert!(!html.contains("tab=settings"), "{html}"); } /// A signed-in reader is sent to the place the application lives. #[test] fn a_reader_is_sent_to_the_application() { let html = html(&Standing::Reader); assert!(html.contains("section=creator"), "{html}"); assert!(!html.contains(r#"href="/join""#), "{html}"); } /// A creator is not sold something they already have. #[test] fn a_creator_is_offered_the_dashboard_and_no_application() { let html = html(&Standing::Creator); assert!(html.contains("You have creator access"), "{html}"); assert!(!html.contains("section=creator"), "{html}"); assert!(!html.contains("Ready to create"), "{html}"); } /// The payout constraint is the one piece of prose on this page somebody /// can lose money by not reading, so it keeps its link and its emphasis. #[test] fn the_stripe_settlement_warning_survives_intact() { let html = html(&Standing::Visitor); assert!(html.contains("https://stripe.com/global"), "{html}"); assert!( html.contains("USD, CAD, GBP, AUD, NZD or EUR"), "the six settlement currencies are not stated: {html}" ); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(&Standing::Visitor); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }