Skip to main content

max / makenotwork

16.1 KB · 448 lines History Blame Raw
1 //! The use-cases page at `/use-cases`, described.
2 //!
3 //! The second public document, and the first with content worth calling
4 //! content: nine creator profiles, four platform promises, a call to action and
5 //! three onward links. It replaces `templates/pages/use_cases.html`,
6 //! `UseCasesTemplate` and `landing::use_cases_page`.
7 //!
8 //! `/team` proved the mount ([`super::Audience::Anyone`], `4239540d`); this one
9 //! asks whether the vocabulary carries a marketing page. It does, and the two
10 //! places it pushed back are worth the reader's time.
11 //!
12 //! # A card with a bullet list is a region, not a row
13 //!
14 //! `Row` refuses to hold a node, deliberately: "a row part may not carry an
15 //! arbitrary node, which is the door through which a description becomes a
16 //! templating language" (`layout::RowPart::Proportion`). Each use-case card has
17 //! a four-to-five item feature list inside it, so a card is a
18 //! [`RegionKind::Group`] labelled with its title, and the features are a
19 //! [`Node::list`] of rows carrying nothing but a primary.
20 //!
21 //! The four "Everyone gets" cards have a name and a sentence and nothing else,
22 //! so those *are* rows, and they are one `Node::list` rather than four regions.
23 //! Same page, two shapes, and the difference is whether the card contains a
24 //! structure or a sentence.
25 //!
26 //! # The grid is not described, and that is the point
27 //!
28 //! `use-case-grid` was `display: grid` with a column count. There is no grid
29 //! region and there should not be: `RegionKind::Columns` is a kanban of peers
30 //! that choose nothing about each other, which is a different claim. Nine
31 //! sibling groups say what is true -- nine cards, equal, belonging to one
32 //! section -- and how they sit is the design system's answer for every consumer
33 //! rather than this page's CSS. Same call as `/team`'s single-card grid.
34 //!
35 //! # The prices are read per request, from the same place the calculator reads
36 //!
37 //! Every tier line interpolates [`crate::tier_prices::TierPrices`], which is
38 //! `Billing`'s and is derived from `docs/business/assumptions.toml`. The screen
39 //! reads it off `viewer.app` rather than taking a state of its own: a public
40 //! document already has a per-request viewer, and a second copy of the prices
41 //! is a second thing to keep in step with the calculator.
42
43 use makeover_layout as layout;
44 use quasi_router::screen::Row;
45 use quasi_router::{
46 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
47 };
48 use quasi_webview::Webview;
49
50 use crate::tier_prices::TierPrices;
51
52 /// The address, registered whole. See [`super::public_document_mount`].
53 pub const PATH: &str = "/use-cases";
54
55 /// The page's own region, and what the skip link points at.
56 pub const PAGE_REGION: &str = "use-cases";
57
58 /// The nine profiles.
59 const PROFILES: &str = "use-case-profiles";
60
61 /// What every tier includes.
62 const UNIVERSAL: &str = "everyone-gets";
63
64 const MEASURE: layout::Measure = layout::Measure::Wide;
65
66 /// One creator profile.
67 ///
68 /// `anchor` is the template's `id=`, kept because the marketing site links to
69 /// these cards directly (`/use-cases#podcasters`) and a conversion that drops
70 /// the id breaks somebody else's link rather than this page.
71 struct Profile {
72 anchor: &'static str,
73 title: &'static str,
74 who: &'static str,
75 description: &'static str,
76 features: &'static [&'static str],
77 /// Which tier line the card ends with. See [`Priced`].
78 priced: Priced,
79 }
80
81 /// How a card states its price.
82 ///
83 /// The template wrote three shapes of tier line by hand, and one of them
84 /// (Educators) names two tiers. Naming the shape rather than the string is what
85 /// keeps every price on this page coming from `TierPrices` -- a formatted string
86 /// in the table here would be a fourth place a price can go stale.
87 #[derive(Clone, Copy)]
88 enum Priced {
89 Basic,
90 SmallFiles,
91 BigFiles,
92 /// "Basic $X/mo or Small Files $Y/mo", which is Educators and only
93 /// Educators: the work fits in either tier depending on what is uploaded.
94 BasicOrSmallFiles,
95 }
96
97 impl Priced {
98 /// The line the card ends with, built from the live prices.
99 fn line(self, prices: &TierPrices) -> String {
100 match self {
101 Self::Basic => format!(
102 "Basic ${}/mo · {}, {}/file",
103 prices.basic_std, prices.basic_total, prices.basic_per_file
104 ),
105 Self::SmallFiles => format!(
106 "Small Files ${}/mo · {}, {}/file",
107 prices.small_files_std, prices.small_files_total, prices.small_files_per_file
108 ),
109 Self::BigFiles => format!(
110 "Big Files ${}/mo · {}, {}/file",
111 prices.big_files_std, prices.big_files_total, prices.big_files_per_file
112 ),
113 Self::BasicOrSmallFiles => format!(
114 "Basic ${}/mo or Small Files ${}/mo",
115 prices.basic_std, prices.small_files_std
116 ),
117 }
118 }
119 }
120
121 /// The nine, in the order the template listed them.
122 const PROFILE_LIST: &[Profile] = &[
123 Profile {
124 anchor: "musicians",
125 title: "Musicians",
126 who: "Bands, solo artists, producers",
127 description: "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.",
128 features: &[
129 "MP3, FLAC, WAV, OGG, AAC, AIFF upload",
130 "In-browser audio player with chapters",
131 "Cover art and rich metadata",
132 "RSS feed per project",
133 "Pay-what-you-want pricing",
134 ],
135 priced: Priced::SmallFiles,
136 },
137 Profile {
138 anchor: "podcasters",
139 title: "Podcasters",
140 who: "Independent shows, networks",
141 description: "Host and distribute your podcast with full RSS support for every major directory.",
142 features: &[
143 "RSS for Apple, Spotify, Pocket Casts",
144 "Chapter markers",
145 "Subscriber-only feeds",
146 "Broadcast emails to followers",
147 ],
148 priced: Priced::SmallFiles,
149 },
150 Profile {
151 anchor: "writers",
152 title: "Writers",
153 who: "Bloggers, newsletter writers, authors",
154 description: "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.",
155 features: &[
156 "Markdown editor with formatting",
157 "Per-project blog",
158 "RSS feed",
159 "Scheduled publishing",
160 "Broadcast emails",
161 ],
162 priced: Priced::Basic,
163 },
164 Profile {
165 anchor: "developers",
166 title: "Software Developers",
167 who: "Indie devs, tool makers, plugin authors",
168 description: "Distribute software with versioned releases, license keys, and a built-in git browser.",
169 features: &[
170 "Versioned releases with changelogs",
171 "License keys with activation tracking",
172 "Git source browser",
173 "Promo codes and discounts",
174 ],
175 priced: Priced::SmallFiles,
176 },
177 Profile {
178 anchor: "sample-packs",
179 title: "Sample Packs",
180 who: "Sound designers, beat makers, foley artists",
181 description: "Sell sample packs and sound effects with audio previews and versioned downloads.",
182 features: &[
183 "Audio previews in-browser",
184 "Versioned downloads",
185 "Hierarchical tags for organization",
186 "Pay-what-you-want pricing",
187 "Cover art",
188 ],
189 priced: Priced::SmallFiles,
190 },
191 Profile {
192 anchor: "digital-art",
193 title: "Digital Art & Photography",
194 who: "Illustrators, photographers, font designers",
195 description: "Sell digital art, photography, and fonts with versioned releases and subscription tiers.",
196 features: &[
197 "Any file type supported",
198 "Versioned releases",
199 "Subscription tiers",
200 "Cover images",
201 ],
202 priced: Priced::SmallFiles,
203 },
204 Profile {
205 anchor: "educators",
206 title: "Educators",
207 who: "Course creators, tutors, workshop leaders",
208 description: "Publish courses and teaching materials with text content, downloads, and subscriptions.",
209 features: &[
210 "Text content with Markdown",
211 "Downloadable materials",
212 "Subscription tiers",
213 "Scheduled publishing",
214 ],
215 priced: Priced::BasicOrSmallFiles,
216 },
217 Profile {
218 anchor: "game-devs",
219 title: "Game Developers",
220 who: "Indie games, mods, TTRPG content",
221 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.",
222 features: &[
223 "Up to 20GB per file (increase on request)",
224 "Versioned downloads with changelogs",
225 "License keys with activation limits",
226 "Promo codes",
227 ],
228 priced: Priced::BigFiles,
229 },
230 Profile {
231 anchor: "comic-creators",
232 title: "Comic Creators",
233 who: "Webcomic artists, graphic novelists",
234 description: "Sell digital comics and graphic novels with project organization and scheduled publishing.",
235 features: &[
236 "PDF, CBZ, and any file type",
237 "Project organization",
238 "Subscriptions",
239 "Scheduled publishing",
240 ],
241 priced: Priced::SmallFiles,
242 },
243 ];
244
245 /// What every tier includes, name and sentence, in the template's order.
246 const UNIVERSAL_LIST: &[(&str, &str)] = &[
247 (
248 "0% platform fee",
249 "Only ~3% payment processing. No percentage cuts, no revenue sharing.",
250 ),
251 (
252 "Full data export",
253 "Download everything anytime. Projects, items, sales, contacts: all yours.",
254 ),
255 (
256 "No lock-in",
257 "Month-to-month. Cancel anytime. Take your audience with you.",
258 ),
259 (
260 "Source available",
261 "Read the codebase. Verify every claim about privacy and data handling.",
262 ),
263 ];
264
265 /// Where the page sends a reader who is convinced, and where it sends one who
266 /// is not yet.
267 const ONWARD: &[(&str, &str)] = &[
268 ("Pricing Calculator", "/pricing"),
269 ("Browse as guest", "/discover"),
270 ("Creator Guide", "/docs/getting-started"),
271 ];
272
273 /// The page. Reads no database, only the prices already in memory.
274 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
275 use axum::extract::FromRef as _;
276
277 let billing = crate::Billing::from_ref(&viewer.app);
278 Ok(page_screen(&billing.tier_prices).into())
279 }
280
281 /// The whole document: the title, the measure, the body.
282 fn page_screen(prices: &TierPrices) -> Described {
283 let mut profiles = Slot::new(PROFILES, RegionKind::Group);
284 for profile in PROFILE_LIST {
285 profiles = profiles.with(Node::Region(card(profile, prices)));
286 }
287
288 let universal = Node::list(
289 UNIVERSAL_LIST
290 .iter()
291 .map(|(name, description)| Row::new(*name).secondary(*description)),
292 );
293
294 let mut page = Slot::new(PAGE_REGION, RegionKind::Pane)
295 .with(Node::page("Use Cases"))
296 .with(Node::text(
297 "A flat monthly fee. 0% platform cut. Who it's built for:",
298 ))
299 .with(Node::section("Available now"))
300 .with(Node::Region(profiles))
301 .with(Node::section("Everyone gets"))
302 .with(Node::Region(
303 Slot::new(UNIVERSAL, RegionKind::Group).with(universal),
304 ))
305 .with(Node::act(
306 "Join the Alpha",
307 Action::get("/join").navigating(),
308 ));
309
310 for (label, route) in ONWARD {
311 page = page.with(Node::act(*label, Action::get(*route).navigating()));
312 }
313
314 Described::single("Use Cases - Makenotwork")
315 .measured(MEASURE)
316 .documented(
317 Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"])),
318 )
319 .summarised(
320 "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \
321 developers and six more kinds of creator.",
322 )
323 .with(page)
324 }
325
326 /// One profile card: who it is for, what they get, what it costs.
327 ///
328 /// The region is identified by the template's anchor so `/use-cases#podcasters`
329 /// still lands on the right card.
330 fn card(profile: &Profile, prices: &TierPrices) -> Slot {
331 let features = Node::list(profile.features.iter().map(|feature| Row::new(*feature)));
332
333 Slot::new(profile.anchor, RegionKind::Group)
334 .label(profile.title)
335 .with(Node::text(profile.who))
336 .with(Node::text(profile.description))
337 .with(features)
338 .with(Node::text(profile.priced.line(prices)))
339 }
340
341 /// The document this screen is drawn in. Same shape as `/team`'s: the skip
342 /// link and the site header, which reads the viewer's user through an `Option`
343 /// because on this mount there may not be one.
344 #[must_use]
345 pub fn renderer(viewer: &super::Viewer) -> Webview {
346 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
347 "{}{}",
348 crate::shell::skip_link(PAGE_REGION),
349 crate::shell::site_header(viewer.user.as_ref()),
350 )))
351 }
352
353 #[cfg(test)]
354 mod tests {
355 use super::*;
356
357 fn prices() -> TierPrices {
358 TierPrices::default()
359 }
360
361 fn html() -> String {
362 use quasi_axum::Serves as _;
363
364 Webview::new().screen(&page_screen(&prices()))
365 }
366
367 /// `2790e5c4`. The template wrote the measure on the body and
368 /// `use-cases-page` on a container div; a described document has no
369 /// container, so both land on the body. Same merge `/team` found.
370 #[test]
371 fn the_document_carries_the_classes_the_template_carried() {
372 let screen = page_screen(&prices());
373
374 assert_eq!(
375 screen.document.body_class.as_deref(),
376 Some("padded-page use-cases-page")
377 );
378 let rendered = html();
379 assert!(
380 rendered.contains("class=\"padded-page use-cases-page\""),
381 "{rendered}"
382 );
383 }
384
385 /// Every card the template drew is still drawn, and still reachable by the
386 /// anchor the marketing links use.
387 #[test]
388 fn every_profile_keeps_its_card_and_its_anchor() {
389 let html = html();
390
391 assert_eq!(PROFILE_LIST.len(), 9, "the template drew nine");
392 for profile in PROFILE_LIST {
393 assert!(html.contains(profile.anchor), "{} missing", profile.anchor);
394 assert!(html.contains(profile.who), "{} missing", profile.who);
395 for feature in profile.features {
396 assert!(
397 html.contains(feature),
398 "{feature} missing from {}",
399 profile.title
400 );
401 }
402 }
403 }
404
405 /// No price is written into this module. Each tier line is built from
406 /// `TierPrices`, so a price change in `assumptions.toml` moves this page
407 /// with it.
408 #[test]
409 fn every_tier_line_comes_from_the_live_prices() {
410 let mut prices = prices();
411 prices.basic_std = 4321;
412 prices.small_files_std = 5678;
413 prices.big_files_std = 8765;
414
415 let html = {
416 use quasi_axum::Serves as _;
417 Webview::new().screen(&page_screen(&prices))
418 };
419
420 assert!(html.contains("4321"), "the Basic price is not read: {html}");
421 assert!(html.contains("5678"), "the Small Files price is not read");
422 assert!(html.contains("8765"), "the Big Files price is not read");
423 }
424
425 /// The educators card is the only one naming two tiers, and it is the case
426 /// a formatted string in the table would have quietly flattened.
427 #[test]
428 fn the_educators_card_names_both_tiers_it_fits_in() {
429 let mut prices = prices();
430 prices.basic_std = 4321;
431 prices.small_files_std = 5678;
432
433 let line = Priced::BasicOrSmallFiles.line(&prices);
434
435 assert!(line.contains("4321") && line.contains("5678"), "{line}");
436 }
437
438 /// `736f45a5`: this screen's markup carries none of the four spellings.
439 #[test]
440 fn the_page_spells_no_spinner() {
441 let html = html();
442
443 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
444 assert!(!html.contains(spelling), "{spelling} survives in {html}");
445 }
446 }
447 }
448