Skip to main content

max / makenotwork

17.3 KB · 468 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). [`Table`] says it, the
10 //! same builder `/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_declare::declare;
41 use quasi_router::screen::Figure;
42 use quasi_router::{Document, Request, Response, RouteError};
43 use quasi_webview::Webview;
44
45 use crate::db;
46 use crate::tier_prices::TierPrices;
47
48 /// The address, registered whole. See [`super::public_document_mount`].
49 pub const PATH: &str = "/creators";
50
51 /// The page's own region, and what the skip link points at.
52 pub const PAGE_REGION: &str = "creators";
53
54 const MEASURE: layout::Measure = layout::Measure::Wide;
55
56 /// What one tier's monthly fee says, by the name the copy gives it.
57 ///
58 /// The tier table was a `const TIERS: &[Tier]` here, whose rows carried two
59 /// `fn(&TierPrices)` pointers. A `const` is a path, so a loop over it opens a
60 /// scope per request and the four rows were rebuilt on every load; read out of
61 /// `content/creators.toml` instead they are unrolled at macro time and fold
62 /// into the residual's literals, with only the two figures left as holes.
63 ///
64 /// What it costs is exhaustiveness, which is `/use-cases`' trade exactly: a
65 /// proc macro cannot evaluate a path, so the file names a tier as a `&str` and
66 /// this matches it. [`tests::every_priced_name_in_the_copy_is_one_of_the_four`]
67 /// is what buys it back.
68 ///
69 /// # Panics
70 ///
71 /// On a name this does not know, which is a content file naming a tier that
72 /// does not exist. The test above makes that a test failure rather than a page
73 /// that renders a blank column.
74 fn tier_price(priced: &str, prices: &TierPrices) -> String {
75 let monthly = match priced {
76 "basic" => prices.basic_std,
77 "small-files" => prices.small_files_std,
78 "big-files" => prices.big_files_std,
79 "everything" => prices.everything_std,
80 other => panic!("content/creators.toml names a tier that does not exist: {other}"),
81 };
82 format!("${monthly}")
83 }
84
85 /// The storage envelope one tier buys. See [`tier_price`].
86 ///
87 /// # Panics
88 ///
89 /// On a name this does not know, for the same reason.
90 fn tier_storage(priced: &str, prices: &TierPrices) -> String {
91 match priced {
92 "basic" => prices.basic_total.clone(),
93 "small-files" => prices.small_files_total.clone(),
94 "big-files" => prices.big_files_total.clone(),
95 "everything" => prices.everything_total.clone(),
96 other => panic!("content/creators.toml names a tier that does not exist: {other}"),
97 }
98 }
99
100 /// Every name the two above answer to, which is what the copy is held to.
101 #[cfg(test)]
102 const TIER_NAMES: &[&str] = &["basic", "small-files", "big-files", "everything"];
103
104 /// What this request knows about the reader's standing.
105 pub(crate) enum Standing {
106 /// Nobody signed in.
107 Visitor,
108 /// Signed in, not yet a creator.
109 Reader,
110 /// Already has creator access.
111 Creator,
112 }
113
114 impl Standing {
115 /// Nobody signed in.
116 ///
117 /// Three predicates rather than a pattern, because the form reaches a value
118 /// through a name and not through a match arm, and each of the three offers
119 /// a different number of things afterwards.
120 const fn is_visitor(&self) -> bool {
121 matches!(self, Self::Visitor)
122 }
123
124 /// Signed in, not yet a creator.
125 const fn is_reader(&self) -> bool {
126 matches!(self, Self::Reader)
127 }
128
129 /// Already has creator access.
130 const fn is_creator(&self) -> bool {
131 matches!(self, Self::Creator)
132 }
133 }
134
135 /// The page.
136 /// The three things this page reads, for the mount that serves it from a
137 /// residual.
138 ///
139 /// One read, stating the document and filling the holes.
140 pub(crate) fn reading(viewer: &super::Viewer) -> Result<(Standing, i64, TierPrices), RouteError> {
141 use axum::extract::FromRef as _;
142
143 let total_creators = viewer
144 .block_on(db::waitlist::count_active_creators(&viewer.app.db))
145 .map_err(|_| RouteError::internal("the creator count could not be read"))?;
146
147 let standing = match viewer.user.as_ref() {
148 None => Standing::Visitor,
149 Some(user) if user.can_create_projects => Standing::Creator,
150 Some(_) => Standing::Reader,
151 };
152
153 let billing = crate::Billing::from_ref(&viewer.app);
154
155 Ok((standing, total_creators, billing.tier_prices))
156 }
157
158 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
159 let (standing, total_creators, prices) = reading(viewer)?;
160
161 Ok(page_screen(&standing, total_creators, &prices).into())
162 }
163
164 declare! {
165 /// The whole document: the title, the measure, the body.
166 pub(crate) shape page_screen(
167 standing: &Standing,
168 total_creators: i64,
169 prices: &TierPrices,
170 ) -> Screen;
171
172 screen single "Creators - Makenotwork" {
173 measured MEASURE;
174 documented Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"]));
175 summarised "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \
176 tiers that pick a file-size envelope rather than a feature set.";
177
178 include page_region(standing, total_creators, prices);
179 }
180 }
181
182 declare! {
183 /// The page's one region, split out so it can be staged.
184 ///
185 /// Almost all of it is prose this repository wrote, so almost all of it
186 /// folds into one literal. What is left varying is the creator count, the
187 /// four tier rows' two figures each, and which of the three calls to action
188 /// this reader is shown.
189 #[staged]
190 pub(crate) shape page_region(
191 standing: &Standing,
192 total_creators: i64,
193 prices: &TierPrices,
194 ) -> Slot;
195
196 region PAGE_REGION as Pane {
197 page "Become a Creator";
198 text "Anyone can sign up to browse and buy. To create projects and sell your work, \
199 apply for creator access. Most applications are approved within a few days. \
200 Makenotwork is in private alpha; we're approving applications one cohort at a \
201 time.";
202
203 section "How It Works";
204 include super::own_prose(
205 "1. **Sign up** and verify your email\n\
206 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
207 3. **Get approved**: we review applications individually, usually within a few days\n\
208 \n\
209 We review applications to make sure applicants are here to share and sell creative \
210 work. If you make something and want to sell it, you'll likely get in. Link to your \
211 existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\
212 \n\
213 **Important:** You sell in the currency your Stripe account settles in, and \
214 receiving payouts requires a [Stripe](https://stripe.com/global) account in a \
215 supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \
216 NZD or EUR**. Check both with Stripe before applying."
217 );
218
219 stats [Figure::new(total_creators.to_string(), "Active Creators")];
220
221 section "Pricing";
222 text "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments \
223 is the payment processor's fee (~3%).";
224 include tier_table(prices);
225 include super::own_prose(
226 "Every tier is the complete platform: `/u/username` profile, project and item pages, \
227 project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
228 analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
229 not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \
230 NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \
231 supported country. [Full tier details](/docs/tiers) | \
232 [Pricing models](/docs/pricing)"
233 );
234 include super::own_prose(
235 "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
236 you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
237 up."
238 );
239
240 section "Who Runs This";
241 include super::own_prose(
242 "Makenotwork is built and operated by one person. No investors, no board, no outside \
243 pressure. Decisions are fast and aligned with creators, but there's no large team \
244 behind the scenes. Read the full picture in our \
245 [continuity guarantee](/docs/guarantees#continuity) and \
246 [platform economics](/docs/economics)."
247 );
248
249 // The one part of the page that differs by who is asking, spread
250 // where it was appended. `feeds` does the same with its body.
251 include each call_to_action(standing);
252 }
253 }
254
255 declare! {
256 /// The four tiers, priced from the live figures.
257 ///
258 /// Four columns and four cells, written together, with no branch between
259 /// them: every tier is a full row, so position is checkable by eye here and
260 /// naming the columns would be ceremony.
261 #[staged]
262 shape tier_table(prices: &TierPrices) -> Node;
263
264 table {
265 column "Tier" {
266 width Content;
267 priority Essential;
268 }
269 column "Monthly" {
270 width Content;
271 }
272 column "Best For" {
273 width Fill;
274 }
275 column "Storage" {
276 width Content;
277 }
278
279 for tier in copy "content/creators.toml" as tiers {
280 cells {
281 cell tier.name;
282 cell tier_price(tier.priced, prices);
283 cell tier.best_for;
284 cell tier_storage(tier.priced, prices);
285 }
286 }
287 }
288 }
289
290 declare! {
291 /// What the page asks of this reader, which is the only thing on it that
292 /// differs by who is asking.
293 ///
294 /// A panel rather than a `Slot` taken and handed back: each standing offers
295 /// a different number of things -- a visitor two controls, the other two one
296 /// each -- so the answer is a run of members and not one node. The three
297 /// guards are exhaustive and disjoint by construction.
298 #[staged]
299 shape call_to_action(standing: &Standing) -> Vec<Node>;
300
301 text "You have creator access." when standing.is_creator();
302 act "Go to Dashboard" to get "/dashboard" navigating when standing.is_creator();
303
304 text "Ready to create?" when standing.is_reader();
305 act "Apply from Dashboard" to get "/dashboard?tab=settings&section=creator" navigating
306 when standing.is_reader();
307
308 text "Join to get started." when standing.is_visitor();
309 act "Join" to get "/join" navigating when standing.is_visitor();
310 act "Login" to get "/login" navigating when standing.is_visitor();
311 }
312
313 /// The document this screen is drawn in.
314 #[must_use]
315 pub fn renderer(viewer: &super::Viewer) -> Webview {
316 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
317 "{}{}",
318 crate::shell::skip_link(PAGE_REGION),
319 crate::shell::site_header(viewer.user.as_ref()),
320 )))
321 }
322
323 #[cfg(test)]
324 mod tests {
325 use super::*;
326
327 fn html(standing: &Standing) -> String {
328 use quasi_axum::Serves as _;
329
330 Webview::new().screen(&page_screen(standing, 7, &TierPrices::default()))
331 }
332
333 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
334 #[test]
335 fn the_document_carries_the_classes_the_template_carried() {
336 let screen = page_screen(&Standing::Visitor, 0, &TierPrices::default());
337
338 assert_eq!(
339 screen.document.body_class.as_deref(),
340 Some("padded-page creators-page")
341 );
342 let rendered = html(&Standing::Visitor);
343 assert!(
344 rendered.contains("class=\"padded-page creators-page\""),
345 "{rendered}"
346 );
347 }
348
349 /// Every tier the table listed is still listed, and its price is read
350 /// rather than written.
351 #[test]
352 fn every_tier_is_priced_from_the_live_figures() {
353 let prices = TierPrices {
354 basic_std: 4321,
355 small_files_std: 5678,
356 big_files_std: 8765,
357 everything_std: 9876,
358 ..TierPrices::default()
359 };
360
361 let html = {
362 use quasi_axum::Serves as _;
363 Webview::new().screen(&page_screen(&Standing::Visitor, 0, &prices))
364 };
365
366 let tiers = copy_tiers();
367 assert_eq!(tiers.len(), 4);
368 for tier in &tiers {
369 for said in ["name", "best_for"] {
370 let words = tier[said].as_str().expect("a string");
371 assert!(html.contains(words), "{words} missing");
372 }
373 }
374 for price in ["4321", "5678", "8765", "9876"] {
375 assert!(html.contains(price), "{price} is not read from TierPrices");
376 }
377 }
378
379 /// The tier rows, read the way the macro reads them.
380 fn copy_tiers() -> Vec<toml::Table> {
381 let copy: toml::Table = include_str!("../../content/creators.toml")
382 .parse()
383 .expect("the creators copy is TOML");
384
385 copy["tiers"]
386 .as_array()
387 .expect("a list of tiers")
388 .iter()
389 .map(|tier| tier.as_table().expect("a table").clone())
390 .collect()
391 }
392
393 /// Every tier the copy names is one the two pricing functions answer to.
394 ///
395 /// What buys back the exhaustiveness the copy move cost. `tier_price`
396 /// panics on a name it does not know, and this is what makes that a test
397 /// failure rather than a page that renders a blank column.
398 #[test]
399 fn every_priced_name_in_the_copy_is_one_of_the_four() {
400 for tier in copy_tiers() {
401 let priced = tier["priced"].as_str().expect("a string");
402 assert!(
403 TIER_NAMES.contains(&priced),
404 "{priced} is not a tier the price functions answer to",
405 );
406 }
407 }
408
409 /// The live disclosure: how many creators are actually here.
410 #[test]
411 fn the_active_creator_count_is_shown() {
412 assert!(html(&Standing::Visitor).contains('7'));
413 assert!(html(&Standing::Visitor).contains("Active Creators"));
414 }
415
416 /// A visitor is offered an account, not an application they cannot file.
417 #[test]
418 fn a_visitor_is_offered_both_ways_in() {
419 let html = html(&Standing::Visitor);
420
421 assert!(html.contains(r#"href="/join""#), "{html}");
422 assert!(html.contains(r#"href="/login""#), "{html}");
423 assert!(!html.contains("tab=settings"), "{html}");
424 }
425
426 /// A signed-in reader is sent to the place the application lives.
427 #[test]
428 fn a_reader_is_sent_to_the_application() {
429 let html = html(&Standing::Reader);
430
431 assert!(html.contains("section=creator"), "{html}");
432 assert!(!html.contains(r#"href="/join""#), "{html}");
433 }
434
435 /// A creator is not sold something they already have.
436 #[test]
437 fn a_creator_is_offered_the_dashboard_and_no_application() {
438 let html = html(&Standing::Creator);
439
440 assert!(html.contains("You have creator access"), "{html}");
441 assert!(!html.contains("section=creator"), "{html}");
442 assert!(!html.contains("Ready to create"), "{html}");
443 }
444
445 /// The payout constraint is the one piece of prose on this page somebody
446 /// can lose money by not reading, so it keeps its link and its emphasis.
447 #[test]
448 fn the_stripe_settlement_warning_survives_intact() {
449 let html = html(&Standing::Visitor);
450
451 assert!(html.contains("https://stripe.com/global"), "{html}");
452 assert!(
453 html.contains("USD, CAD, GBP, AUD, NZD or EUR"),
454 "the six settlement currencies are not stated: {html}"
455 );
456 }
457
458 /// `736f45a5`: this screen's markup carries none of the four spellings.
459 #[test]
460 fn the_page_spells_no_spinner() {
461 let html = html(&Standing::Visitor);
462
463 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
464 assert!(!html.contains(spelling), "{spelling} survives in {html}");
465 }
466 }
467 }
468