Skip to main content

max / makenotwork

13.7 KB · 366 lines History Blame Raw
1 //! The creator-application page at `/creators`, described.
2 //!
3 //! The fifth public document. It replaces `templates/pages/creators.html`,
4 //! `CreatorsTemplate` and `pages::creators_page`.
5 //!
6 //! # The tier table is a table, and it is the first described one on a public page
7 //!
8 //! Four tiers by four columns, and every cell in two of those columns comes
9 //! from [`TierPrices`](crate::tier_prices::TierPrices). `Node::Table` says it,
10 //! the same member `/feed` uses for its item list, so the widths and the
11 //! narrow-viewport behaviour are the design system's rather than
12 //! `.wave-table`'s.
13 //!
14 //! Where `/use-cases` said the same prices as prose inside nine cards, this
15 //! says them as a grid, which is what the shipped page did too. Both read the
16 //! one `TierPrices`, so the two pages cannot disagree about what Basic costs.
17 //!
18 //! # Three readers again, and the third one is new
19 //!
20 //! [`super::Audience::Anyone`] carries a fourth kind of branch here. `/fan-plus`
21 //! split on whether the reader had bought; this splits on what the reader is
22 //! allowed to do:
23 //!
24 //! ```text
25 //! a visitor Join, and Login
26 //! a reader Apply, from the dashboard
27 //! a creator nothing to apply for; go to the dashboard
28 //! ```
29 //!
30 //! `can_create_projects` is the flag, read off the session user the factory
31 //! already resolved, so the branch costs no query.
32 //!
33 //! # The count is live and the page says so
34 //!
35 //! `total_creators` is read at request time. It is the one number on this page
36 //! that is not a price, and the disclosure is the point: a person deciding
37 //! whether to apply is told how many creators are actually here.
38
39 use makeover_layout as layout;
40 use quasi_router::screen::{Cell, Cells, Column, Figure};
41 use quasi_router::{
42 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
43 };
44 use quasi_webview::Webview;
45
46 use crate::db;
47 use crate::tier_prices::TierPrices;
48
49 /// The address, registered whole. See [`super::public_document_mount`].
50 pub const PATH: &str = "/creators";
51
52 /// The page's own region, and what the skip link points at.
53 pub const PAGE_REGION: &str = "creators";
54
55 const MEASURE: layout::Measure = layout::Measure::Wide;
56
57 /// One row of the tier table.
58 ///
59 /// `price` and `storage` are read off [`TierPrices`] rather than written here,
60 /// for the reason `/use-cases` gives: a formatted price in a table is a price
61 /// that goes stale on its own.
62 struct Tier {
63 name: &'static str,
64 best_for: &'static str,
65 price: fn(&TierPrices) -> i32,
66 storage: fn(&TierPrices) -> String,
67 }
68
69 /// The four, in the order the shipped table listed them.
70 const TIERS: &[Tier] = &[
71 Tier {
72 name: "Basic",
73 best_for: "Text, blogs, newsletters",
74 price: |p| p.basic_std,
75 storage: |p| p.basic_total.clone(),
76 },
77 Tier {
78 name: "Small Files",
79 best_for: "Audio, plugins, small software",
80 price: |p| p.small_files_std,
81 storage: |p| p.small_files_total.clone(),
82 },
83 Tier {
84 name: "Big Files",
85 best_for: "Video, games, large software",
86 price: |p| p.big_files_std,
87 storage: |p| p.big_files_total.clone(),
88 },
89 Tier {
90 name: "Everything",
91 best_for: "All features, current and future",
92 price: |p| p.everything_std,
93 storage: |p| p.everything_total.clone(),
94 },
95 ];
96
97 /// What this request knows about the reader's standing.
98 enum Standing {
99 /// Nobody signed in.
100 Visitor,
101 /// Signed in, not yet a creator.
102 Reader,
103 /// Already has creator access.
104 Creator,
105 }
106
107 /// The page.
108 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
109 use axum::extract::FromRef as _;
110
111 let total_creators = viewer
112 .block_on(db::waitlist::count_active_creators(&viewer.app.db))
113 .map_err(|_| RouteError::internal("the creator count could not be read"))?;
114
115 let standing = match viewer.user.as_ref() {
116 None => Standing::Visitor,
117 Some(user) if user.can_create_projects => Standing::Creator,
118 Some(_) => Standing::Reader,
119 };
120
121 let billing = crate::Billing::from_ref(&viewer.app);
122
123 Ok(page_screen(&standing, total_creators, &billing.tier_prices).into())
124 }
125
126 /// The whole document: the title, the measure, the body.
127 fn page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Described {
128 let page = Slot::new(PAGE_REGION, RegionKind::Pane)
129 .with(Node::page("Become a Creator"))
130 .with(Node::text(
131 "Anyone can sign up to browse and buy. To create projects and sell your work, apply \
132 for creator access. Most applications are approved within a few days. Makenotwork is \
133 in private alpha; we're approving applications one cohort at a time.",
134 ))
135 .with(Node::section("How It Works"))
136 .with(super::own_prose(
137 "1. **Sign up** and verify your email\n\
138 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
139 3. **Get approved**: we review applications individually, usually within a few days\n\
140 \n\
141 We review applications to make sure applicants are here to share and sell creative \
142 work. If you make something and want to sell it, you'll likely get in. Link to your \
143 existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\
144 \n\
145 **Important:** You sell in the currency your Stripe account settles in, and \
146 receiving payouts requires a [Stripe](https://stripe.com/global) account in a \
147 supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \
148 NZD or EUR**. Check both with Stripe before applying.",
149 ))
150 .with(Node::stats([Figure::new(
151 total_creators.to_string(),
152 "Active Creators",
153 )]))
154 .with(Node::section("Pricing"))
155 .with(Node::text(
156 "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is \
157 the payment processor's fee (~3%).",
158 ))
159 .with(tier_table(prices))
160 .with(super::own_prose(
161 "Every tier is the complete platform: `/u/username` profile, project and item pages, \
162 project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
163 analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
164 not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \
165 NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \
166 supported country. [Full tier details](/docs/tiers) | \
167 [Pricing models](/docs/pricing)",
168 ))
169 .with(super::own_prose(
170 "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
171 you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
172 up.",
173 ))
174 .with(Node::section("Who Runs This"))
175 .with(super::own_prose(
176 "Makenotwork is built and operated by one person. No investors, no board, no outside \
177 pressure. Decisions are fast and aligned with creators, but there's no large team \
178 behind the scenes. Read the full picture in our \
179 [continuity guarantee](/docs/guarantees#continuity) and \
180 [platform economics](/docs/economics).",
181 ));
182
183 let page = call_to_action(page, standing);
184
185 Described::single("Creators - Makenotwork")
186 .measured(MEASURE)
187 .documented(
188 Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])),
189 )
190 .summarised(
191 "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \
192 tiers that pick a file-size envelope rather than a feature set.",
193 )
194 .with(page)
195 }
196
197 /// The four tiers, priced from the live figures.
198 fn tier_table(prices: &TierPrices) -> Node {
199 Node::Table {
200 columns: vec![
201 Column::new("Tier")
202 .width(layout::Width::Content)
203 .priority(layout::Priority::Essential),
204 Column::new("Monthly").width(layout::Width::Content),
205 Column::new("Best For").width(layout::Width::Fill),
206 Column::new("Storage").width(layout::Width::Content),
207 ],
208 rows: TIERS
209 .iter()
210 .map(|tier| {
211 Cells::new([
212 Cell::new(tier.name),
213 Cell::new(format!("${}", (tier.price)(prices))),
214 Cell::new(tier.best_for),
215 Cell::new((tier.storage)(prices)),
216 ])
217 })
218 .collect(),
219 more: None,
220 }
221 }
222
223 /// What the page asks of this reader, which is the only thing on it that
224 /// differs by who is asking.
225 fn call_to_action(page: Slot, standing: &Standing) -> Slot {
226 match standing {
227 Standing::Creator => page
228 .with(Node::text("You have creator access."))
229 .with(Node::act(
230 "Go to Dashboard",
231 Action::get("/dashboard").navigating(),
232 )),
233 Standing::Reader => page.with(Node::text("Ready to create?")).with(Node::act(
234 "Apply from Dashboard",
235 Action::get("/dashboard?tab=settings&section=creator").navigating(),
236 )),
237 Standing::Visitor => page
238 .with(Node::text("Join to get started."))
239 .with(Node::act("Join", Action::get("/join").navigating()))
240 .with(Node::act("Login", Action::get("/login").navigating())),
241 }
242 }
243
244 /// The document this screen is drawn in.
245 #[must_use]
246 pub fn renderer(viewer: &super::Viewer) -> Webview {
247 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
248 "{}{}",
249 crate::shell::skip_link(PAGE_REGION),
250 crate::shell::site_header(viewer.user.as_ref()),
251 )))
252 }
253
254 #[cfg(test)]
255 mod tests {
256 use super::*;
257
258 fn html(standing: &Standing) -> String {
259 use quasi_axum::Serves as _;
260
261 Webview::new().screen(&page_screen(standing, 7, &TierPrices::default()))
262 }
263
264 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
265 #[test]
266 fn the_document_carries_the_classes_the_template_carried() {
267 let screen = page_screen(&Standing::Visitor, 0, &TierPrices::default());
268
269 assert_eq!(
270 screen.document.body_class.as_deref(),
271 Some("padded-page creators-page")
272 );
273 let rendered = html(&Standing::Visitor);
274 assert!(
275 rendered.contains("class=\"padded-page creators-page\""),
276 "{rendered}"
277 );
278 }
279
280 /// Every tier the table listed is still listed, and its price is read
281 /// rather than written.
282 #[test]
283 fn every_tier_is_priced_from_the_live_figures() {
284 let prices = TierPrices {
285 basic_std: 4321,
286 small_files_std: 5678,
287 big_files_std: 8765,
288 everything_std: 9876,
289 ..TierPrices::default()
290 };
291
292 let html = {
293 use quasi_axum::Serves as _;
294 Webview::new().screen(&page_screen(&Standing::Visitor, 0, &prices))
295 };
296
297 assert_eq!(TIERS.len(), 4);
298 for tier in TIERS {
299 assert!(html.contains(tier.name), "{} missing", tier.name);
300 assert!(html.contains(tier.best_for), "{} missing", tier.best_for);
301 }
302 for price in ["4321", "5678", "8765", "9876"] {
303 assert!(html.contains(price), "{price} is not read from TierPrices");
304 }
305 }
306
307 /// The live disclosure: how many creators are actually here.
308 #[test]
309 fn the_active_creator_count_is_shown() {
310 assert!(html(&Standing::Visitor).contains('7'));
311 assert!(html(&Standing::Visitor).contains("Active Creators"));
312 }
313
314 /// A visitor is offered an account, not an application they cannot file.
315 #[test]
316 fn a_visitor_is_offered_both_ways_in() {
317 let html = html(&Standing::Visitor);
318
319 assert!(html.contains(r#"href="/join""#), "{html}");
320 assert!(html.contains(r#"href="/login""#), "{html}");
321 assert!(!html.contains("tab=settings"), "{html}");
322 }
323
324 /// A signed-in reader is sent to the place the application lives.
325 #[test]
326 fn a_reader_is_sent_to_the_application() {
327 let html = html(&Standing::Reader);
328
329 assert!(html.contains("section=creator"), "{html}");
330 assert!(!html.contains(r#"href="/join""#), "{html}");
331 }
332
333 /// A creator is not sold something they already have.
334 #[test]
335 fn a_creator_is_offered_the_dashboard_and_no_application() {
336 let html = html(&Standing::Creator);
337
338 assert!(html.contains("You have creator access"), "{html}");
339 assert!(!html.contains("section=creator"), "{html}");
340 assert!(!html.contains("Ready to create"), "{html}");
341 }
342
343 /// The payout constraint is the one piece of prose on this page somebody
344 /// can lose money by not reading, so it keeps its link and its emphasis.
345 #[test]
346 fn the_stripe_settlement_warning_survives_intact() {
347 let html = html(&Standing::Visitor);
348
349 assert!(html.contains("https://stripe.com/global"), "{html}");
350 assert!(
351 html.contains("USD, CAD, GBP, AUD, NZD or EUR"),
352 "the six settlement currencies are not stated: {html}"
353 );
354 }
355
356 /// `736f45a5`: this screen's markup carries none of the four spellings.
357 #[test]
358 fn the_page_spells_no_spinner() {
359 let html = html(&Standing::Visitor);
360
361 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
362 assert!(!html.contains(spelling), "{spelling} survives in {html}");
363 }
364 }
365 }
366