//! 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). `Node::Table` says it, //! the same member `/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_router::screen::{Cell, Cells, Column, Figure}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; 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; /// One row of the tier table. /// /// `price` and `storage` are read off [`TierPrices`] rather than written here, /// for the reason `/use-cases` gives: a formatted price in a table is a price /// that goes stale on its own. struct Tier { name: &'static str, best_for: &'static str, price: fn(&TierPrices) -> i32, storage: fn(&TierPrices) -> String, } /// The four, in the order the shipped table listed them. const TIERS: &[Tier] = &[ Tier { name: "Basic", best_for: "Text, blogs, newsletters", price: |p| p.basic_std, storage: |p| p.basic_total.clone(), }, Tier { name: "Small Files", best_for: "Audio, plugins, small software", price: |p| p.small_files_std, storage: |p| p.small_files_total.clone(), }, Tier { name: "Big Files", best_for: "Video, games, large software", price: |p| p.big_files_std, storage: |p| p.big_files_total.clone(), }, Tier { name: "Everything", best_for: "All features, current and future", price: |p| p.everything_std, storage: |p| p.everything_total.clone(), }, ]; /// What this request knows about the reader's standing. enum Standing { /// Nobody signed in. Visitor, /// Signed in, not yet a creator. Reader, /// Already has creator access. Creator, } /// The page. pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { 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(page_screen(&standing, total_creators, &billing.tier_prices).into()) } /// The whole document: the title, the measure, the body. fn page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Described { let page = Slot::new(PAGE_REGION, RegionKind::Pane) .with(Node::page("Become a Creator")) .with(Node::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.", )) .with(Node::section("How It Works")) .with(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.", )) .with(Node::stats([Figure::new( total_creators.to_string(), "Active Creators", )])) .with(Node::section("Pricing")) .with(Node::text( "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is \ the payment processor's fee (~3%).", )) .with(tier_table(prices)) .with(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)", )) .with(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.", )) .with(Node::section("Who Runs This")) .with(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).", )); let page = call_to_action(page, standing); Described::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.", ) .with(page) } /// The four tiers, priced from the live figures. fn tier_table(prices: &TierPrices) -> Node { Node::Table { columns: vec![ Column::new("Tier") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("Monthly").width(layout::Width::Content), Column::new("Best For").width(layout::Width::Fill), Column::new("Storage").width(layout::Width::Content), ], rows: TIERS .iter() .map(|tier| { Cells::new([ Cell::new(tier.name), Cell::new(format!("${}", (tier.price)(prices))), Cell::new(tier.best_for), Cell::new((tier.storage)(prices)), ]) }) .collect(), more: None, } } /// What the page asks of this reader, which is the only thing on it that /// differs by who is asking. fn call_to_action(page: Slot, standing: &Standing) -> Slot { match standing { Standing::Creator => page .with(Node::text("You have creator access.")) .with(Node::act( "Go to Dashboard", Action::get("/dashboard").navigating(), )), Standing::Reader => page.with(Node::text("Ready to create?")).with(Node::act( "Apply from Dashboard", Action::get("/dashboard?tab=settings§ion=creator").navigating(), )), Standing::Visitor => page .with(Node::text("Join to get started.")) .with(Node::act("Join", Action::get("/join").navigating())) .with(Node::act("Login", Action::get("/login").navigating())), } } /// 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)) }; assert_eq!(TIERS.len(), 4); for tier in TIERS { assert!(html.contains(tier.name), "{} missing", tier.name); assert!(html.contains(tier.best_for), "{} missing", tier.best_for); } for price in ["4321", "5678", "8765", "9876"] { assert!(html.contains(price), "{price} is not read from TierPrices"); } } /// 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}"); } } }