max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
7 files changed,
+365 insertions,
-124 deletions
| @@ -53,66 +53,56 @@ | |||
| 53 | 53 | ||
| 54 | 54 | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 55 | 55 | ||
| 56 | - | /// One row of the tier table. | |
| 56 | + | /// What one tier's monthly fee says, by the name the copy gives it. | |
| 57 | 57 | /// | |
| 58 | - | /// The two figures are read off [`TierPrices`] rather than written here, for | |
| 59 | - | /// the reason `/use-cases` gives: a formatted price in a table is a price that | |
| 60 | - | /// goes stale on its own. | |
| 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. | |
| 61 | 63 | /// | |
| 62 | - | /// [`price`](Self::price) and [`storage`](Self::storage) are what the table | |
| 63 | - | /// reads, and the two fields behind them are what those read. A description | |
| 64 | - | /// names what it draws, and `(tier.price)(prices)` is a call through a field | |
| 65 | - | /// rather than a name -- the same ruling policy's `OTHER` got when it was a | |
| 66 | - | /// slice of tuples. | |
| 67 | - | struct Tier { | |
| 68 | - | name: &'static str, | |
| 69 | - | best_for: &'static str, | |
| 70 | - | monthly: fn(&TierPrices) -> i32, | |
| 71 | - | total: fn(&TierPrices) -> String, | |
| 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}") | |
| 72 | 83 | } | |
| 73 | 84 | ||
| 74 | - | impl Tier { | |
| 75 | - | /// The monthly fee, formatted as the table shows it. | |
| 76 | - | fn price(&self, prices: &TierPrices) -> String { | |
| 77 | - | format!("${}", (self.monthly)(prices)) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | /// The storage envelope this tier buys. | |
| 81 | - | fn storage(&self, prices: &TierPrices) -> String { | |
| 82 | - | (self.total)(prices) | |
| 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}"), | |
| 83 | 97 | } | |
| 84 | 98 | } | |
| 85 | 99 | ||
| 86 | - | /// The four, in the order the shipped table listed them. | |
| 87 | - | const TIERS: &[Tier] = &[ | |
| 88 | - | Tier { | |
| 89 | - | name: "Basic", | |
| 90 | - | best_for: "Text, blogs, newsletters", | |
| 91 | - | monthly: |p| p.basic_std, | |
| 92 | - | total: |p| p.basic_total.clone(), | |
| 93 | - | }, | |
| 94 | - | Tier { | |
| 95 | - | name: "Small Files", | |
| 96 | - | best_for: "Audio, plugins, small software", | |
| 97 | - | monthly: |p| p.small_files_std, | |
| 98 | - | total: |p| p.small_files_total.clone(), | |
| 99 | - | }, | |
| 100 | - | Tier { | |
| 101 | - | name: "Big Files", | |
| 102 | - | best_for: "Video, games, large software", | |
| 103 | - | monthly: |p| p.big_files_std, | |
| 104 | - | total: |p| p.big_files_total.clone(), | |
| 105 | - | }, | |
| 106 | - | Tier { | |
| 107 | - | name: "Everything", | |
| 108 | - | best_for: "All features, current and future", | |
| 109 | - | monthly: |p| p.everything_std, | |
| 110 | - | total: |p| p.everything_total.clone(), | |
| 111 | - | }, | |
| 112 | - | ]; | |
| 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"]; | |
| 113 | 103 | ||
| 114 | 104 | /// What this request knows about the reader's standing. | |
| 115 | - | enum Standing { | |
| 105 | + | pub(crate) enum Standing { | |
| 116 | 106 | /// Nobody signed in. | |
| 117 | 107 | Visitor, | |
| 118 | 108 | /// Signed in, not yet a creator. | |
| @@ -143,7 +133,11 @@ | |||
| 143 | 133 | } | |
| 144 | 134 | ||
| 145 | 135 | /// The page. | |
| 146 | - | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 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> { | |
| 147 | 141 | use axum::extract::FromRef as _; | |
| 148 | 142 | ||
| 149 | 143 | let total_creators = viewer | |
| @@ -158,12 +152,22 @@ | |||
| 158 | 152 | ||
| 159 | 153 | let billing = crate::Billing::from_ref(&viewer.app); | |
| 160 | 154 | ||
| 161 | - | Ok(page_screen(&standing, total_creators, &billing.tier_prices).into()) | |
| 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 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | declare! { | |
| 165 | 165 | /// The whole document: the title, the measure, the body. | |
| 166 | - | shape page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Screen; | |
| 166 | + | pub(crate) shape page_screen( | |
| 167 | + | standing: &Standing, | |
| 168 | + | total_creators: i64, | |
| 169 | + | prices: &TierPrices, | |
| 170 | + | ) -> Screen; | |
| 167 | 171 | ||
| 168 | 172 | screen single "Creators - Makenotwork" { | |
| 169 | 173 | measured MEASURE; | |
| @@ -171,65 +175,80 @@ | |||
| 171 | 175 | summarised "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \ | |
| 172 | 176 | tiers that pick a file-size envelope rather than a feature set."; | |
| 173 | 177 | ||
| 174 | - | region PAGE_REGION as Pane { | |
| 175 | - | page "Become a Creator"; | |
| 176 | - | text "Anyone can sign up to browse and buy. To create projects and sell your work, \ | |
| 177 | - | apply for creator access. Most applications are approved within a few days. \ | |
| 178 | - | Makenotwork is in private alpha; we're approving applications one cohort at a \ | |
| 179 | - | time."; | |
| 178 | + | include page_region(standing, total_creators, prices); | |
| 179 | + | } | |
| 180 | + | } | |
| 180 | 181 | ||
| 181 | - | section "How It Works"; | |
| 182 | - | include super::own_prose( | |
| 183 | - | "1. **Sign up** and verify your email\n\ | |
| 184 | - | 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\ | |
| 185 | - | 3. **Get approved**: we review applications individually, usually within a few days\n\ | |
| 186 | - | \n\ | |
| 187 | - | We review applications to make sure applicants are here to share and sell creative \ | |
| 188 | - | work. If you make something and want to sell it, you'll likely get in. Link to your \ | |
| 189 | - | existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\ | |
| 190 | - | \n\ | |
| 191 | - | **Important:** You sell in the currency your Stripe account settles in, and \ | |
| 192 | - | receiving payouts requires a [Stripe](https://stripe.com/global) account in a \ | |
| 193 | - | supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \ | |
| 194 | - | NZD or EUR**. Check both with Stripe before applying." | |
| 195 | - | ); | |
| 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; | |
| 196 | 195 | ||
| 197 | - | stats [Figure::new(total_creators.to_string(), "Active Creators")]; | |
| 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."; | |
| 198 | 202 | ||
| 199 | - | section "Pricing"; | |
| 200 | - | text "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments \ | |
| 201 | - | is the payment processor's fee (~3%)."; | |
| 202 | - | include tier_table(prices); | |
| 203 | - | include super::own_prose( | |
| 204 | - | "Every tier is the complete platform: `/u/username` profile, project and item pages, \ | |
| 205 | - | project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \ | |
| 206 | - | analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \ | |
| 207 | - | not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \ | |
| 208 | - | NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \ | |
| 209 | - | supported country. [Full tier details](/docs/tiers) | \ | |
| 210 | - | [Pricing models](/docs/pricing)" | |
| 211 | - | ); | |
| 212 | - | include super::own_prose( | |
| 213 | - | "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \ | |
| 214 | - | you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \ | |
| 215 | - | up." | |
| 216 | - | ); | |
| 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 | + | ); | |
| 217 | 218 | ||
| 218 | - | section "Who Runs This"; | |
| 219 | - | include super::own_prose( | |
| 220 | - | "Makenotwork is built and operated by one person. No investors, no board, no outside \ | |
| 221 | - | pressure. Decisions are fast and aligned with creators, but there's no large team \ | |
| 222 | - | behind the scenes. Read the full picture in our \ | |
| 223 | - | [continuity guarantee](/docs/guarantees#continuity) and \ | |
| 224 | - | [platform economics](/docs/economics)." | |
| 225 | - | ); | |
| 219 | + | stats [Figure::new(total_creators.to_string(), "Active Creators")]; | |
| 226 | 220 | ||
| 227 | - | // The one part of the page that differs by who is asking, spread | |
| 228 | - | // where it was appended. `feeds` does the same with its body. | |
| 229 | - | for node in call_to_action(standing) { | |
| 230 | - | include node; | |
| 231 | - | } | |
| 232 | - | } | |
| 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); | |
| 233 | 252 | } | |
| 234 | 253 | } | |
| 235 | 254 | ||
| @@ -239,6 +258,7 @@ | |||
| 239 | 258 | /// Four columns and four cells, written together, with no branch between | |
| 240 | 259 | /// them: every tier is a full row, so position is checkable by eye here and | |
| 241 | 260 | /// naming the columns would be ceremony. | |
| 261 | + | #[staged] | |
| 242 | 262 | shape tier_table(prices: &TierPrices) -> Node; | |
| 243 | 263 | ||
| 244 | 264 | table { | |
| @@ -256,12 +276,12 @@ | |||
| 256 | 276 | width Content; | |
| 257 | 277 | } | |
| 258 | 278 | ||
| 259 | - | for tier in TIERS { | |
| 279 | + | for tier in copy "content/creators.toml" as tiers { | |
| 260 | 280 | cells { | |
| 261 | 281 | cell tier.name; | |
| 262 | - | cell tier.price(prices); | |
| 282 | + | cell tier_price(tier.priced, prices); | |
| 263 | 283 | cell tier.best_for; | |
| 264 | - | cell tier.storage(prices); | |
| 284 | + | cell tier_storage(tier.priced, prices); | |
| 265 | 285 | } | |
| 266 | 286 | } | |
| 267 | 287 | } | |
| @@ -275,6 +295,7 @@ | |||
| 275 | 295 | /// a different number of things -- a visitor two controls, the other two one | |
| 276 | 296 | /// each -- so the answer is a run of members and not one node. The three | |
| 277 | 297 | /// guards are exhaustive and disjoint by construction. | |
| 298 | + | #[staged] | |
| 278 | 299 | shape call_to_action(standing: &Standing) -> Vec<Node>; | |
| 279 | 300 | ||
| 280 | 301 | text "You have creator access." when standing.is_creator(); | |
| @@ -342,16 +363,49 @@ | |||
| 342 | 363 | Webview::new().screen(&page_screen(&Standing::Visitor, 0, &prices)) | |
| 343 | 364 | }; | |
| 344 | 365 | ||
| 345 | - | assert_eq!(TIERS.len(), 4); | |
| 346 | - | for tier in TIERS { | |
| 347 | - | assert!(html.contains(tier.name), "{} missing", tier.name); | |
| 348 | - | assert!(html.contains(tier.best_for), "{} missing", tier.best_for); | |
| 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 | + | } | |
| 349 | 373 | } | |
| 350 | 374 | for price in ["4321", "5678", "8765", "9876"] { | |
| 351 | 375 | assert!(html.contains(price), "{price} is not read from TierPrices"); | |
| 352 | 376 | } | |
| 353 | 377 | } | |
| 354 | 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 | + | ||
| 355 | 409 | /// The live disclosure: how many creators are actually here. | |
| 356 | 410 | #[test] | |
| 357 | 411 | fn the_active_creator_count_is_shown() { |
| @@ -272,9 +272,7 @@ | |||
| 272 | 272 | ||
| 273 | 273 | region PAGE_REGION as Pane { | |
| 274 | 274 | page "Your Feed"; | |
| 275 | - | for node in body(page, Surface::Page) { | |
| 276 | - | include node; | |
| 277 | - | } | |
| 275 | + | include each body(page, Surface::Page); | |
| 278 | 276 | } | |
| 279 | 277 | } | |
| 280 | 278 | } |
| @@ -774,7 +774,21 @@ | |||
| 774 | 774 | ), | |
| 775 | 775 | ( | |
| 776 | 776 | creators::PATH, | |
| 777 | - | public_document_mount(app, creators::PATH, creators::screen, creators::renderer), | |
| 777 | + | served_document_mount(app, creators::PATH, creators::renderer, |viewer, _| { | |
| 778 | + | // One read of the standing, the count and the prices, stating | |
| 779 | + | // the document and filling the holes. | |
| 780 | + | let (standing, total_creators, prices) = creators::reading(viewer)?; | |
| 781 | + | Ok(Served { | |
| 782 | + | screen: creators::page_screen(&standing, total_creators, &prices), | |
| 783 | + | markup: creators::page_region_serve( | |
| 784 | + | &residuals::CREATORS, | |
| 785 | + | &standing, | |
| 786 | + | total_creators, | |
| 787 | + | &prices, | |
| 788 | + | ) | |
| 789 | + | .into(), | |
| 790 | + | }) | |
| 791 | + | }), | |
| 778 | 792 | ), | |
| 779 | 793 | ( | |
| 780 | 794 | collections::PATH, |
| @@ -98,6 +98,9 @@ | |||
| 98 | 98 | ("PAYOUT_SUMMARY", |plan| { | |
| 99 | 99 | super::payout_summary::card_staged(plan) | |
| 100 | 100 | }), | |
| 101 | + | ("CREATORS", |plan| { | |
| 102 | + | Node::Region(super::creators::page_region_staged(plan)) | |
| 103 | + | }), | |
| 101 | 104 | ] | |
| 102 | 105 | } | |
| 103 | 106 | ||
| @@ -344,11 +347,43 @@ | |||
| 344 | 347 | } | |
| 345 | 348 | ||
| 346 | 349 | /// Prices no default and no assumptions file would produce. | |
| 350 | + | /// | |
| 351 | + | /// The storage envelopes are set as well as the fees, and `/creators` is | |
| 352 | + | /// why. A cell holding one piece of text says so on its container with | |
| 353 | + | /// `cell-value`, and an empty string is not one piece of text but no | |
| 354 | + | /// content at all -- so an empty cell and a filled one are two shapes, and | |
| 355 | + | /// a residual holds one. `TierPrices::default()` leaves every envelope | |
| 356 | + | /// empty; nothing serving this page does, because they are read from the | |
| 357 | + | /// assumptions file at boot and the validator refuses a missing one. | |
| 358 | + | /// | |
| 359 | + | /// The rule that generalises, and it is the one the converted screens | |
| 360 | + | /// already follow: **a hole that can be empty is guarded**, because empty | |
| 361 | + | /// is a different shape rather than a shorter value. `/c/{username}/{slug}` | |
| 362 | + | /// says `unless loaded.description().is_empty()` for exactly this reason. | |
| 347 | 363 | fn odd_prices() -> crate::tier_prices::TierPrices { | |
| 348 | 364 | crate::tier_prices::TierPrices { | |
| 349 | 365 | basic_std: 4321, | |
| 350 | 366 | small_files_std: 5678, | |
| 351 | 367 | big_files_std: 8765, | |
| 368 | + | everything_std: 9876, | |
| 369 | + | basic_total: "3GB".to_owned(), | |
| 370 | + | small_files_total: "40GB".to_owned(), | |
| 371 | + | big_files_total: "700GB".to_owned(), | |
| 372 | + | everything_total: "9TB".to_owned(), | |
| 373 | + | ..Default::default() | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | /// The envelopes a served page actually carries. | |
| 378 | + | /// | |
| 379 | + | /// `TierPrices::default()` is a test artefact: every envelope is the empty | |
| 380 | + | /// string, which no request produces. See [`odd_prices`]. | |
| 381 | + | fn stated_prices() -> crate::tier_prices::TierPrices { | |
| 382 | + | crate::tier_prices::TierPrices { | |
| 383 | + | basic_total: "1GB".to_owned(), | |
| 384 | + | small_files_total: "20GB".to_owned(), | |
| 385 | + | big_files_total: "500GB".to_owned(), | |
| 386 | + | everything_total: "5TB".to_owned(), | |
| 352 | 387 | ..Default::default() | |
| 353 | 388 | } | |
| 354 | 389 | } | |
| @@ -699,6 +734,60 @@ | |||
| 699 | 734 | } | |
| 700 | 735 | } | |
| 701 | 736 | ||
| 737 | + | /// The creators page fills to what the renderer builds. | |
| 738 | + | /// | |
| 739 | + | /// Three readers and two price sets. The standing decides which call to | |
| 740 | + | /// action is placed, which is the branch, and the prices fill the tier | |
| 741 | + | /// table's eight figures. Prices nothing else in the tree uses, for | |
| 742 | + | /// `/use-cases`' reason: filling with the defaults would pass against a | |
| 743 | + | /// residual that had baked one request's numbers into its literals. | |
| 744 | + | #[test] | |
| 745 | + | fn the_creators_residual_fills_to_what_the_renderer_builds() { | |
| 746 | + | use crate::quasi::creators::{Standing, page_region, page_region_serve}; | |
| 747 | + | use quasi_axum::Serves as _; | |
| 748 | + | ||
| 749 | + | for standing in [Standing::Visitor, Standing::Reader, Standing::Creator] { | |
| 750 | + | for prices in [stated_prices(), odd_prices()] { | |
| 751 | + | for total in [0, 7] { | |
| 752 | + | assert_eq!( | |
| 753 | + | page_region_serve(&CREATORS, &standing, total, &prices), | |
| 754 | + | Webview::new() | |
| 755 | + | .fragment(&Node::Region(page_region(&standing, total, &prices))), | |
| 756 | + | "total={total}", | |
| 757 | + | ); | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | /// The four tier rows are compiled, not rebuilt per request. | |
| 764 | + | /// | |
| 765 | + | /// What says the copy move landed. The rows come out of | |
| 766 | + | /// `content/creators.toml` and are unrolled at macro time, so each tier's | |
| 767 | + | /// name and what it is for are literals here and only its two figures are | |
| 768 | + | /// holes. A loop appearing would mean they went back to being read through | |
| 769 | + | /// a path the macro cannot evaluate. | |
| 770 | + | #[test] | |
| 771 | + | fn the_creators_residual_unrolls_its_tier_table() { | |
| 772 | + | use quasi_router::stage::Op; | |
| 773 | + | ||
| 774 | + | let compiled = format!("{:?}", CREATORS.ops()); | |
| 775 | + | for tier in ["Basic", "Small Files", "Big Files", "Everything"] { | |
| 776 | + | assert!(compiled.contains(tier), "{tier} is not in the residual"); | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | fn loops(ops: &[Op]) -> usize { | |
| 780 | + | ops.iter() | |
| 781 | + | .map(|op| match op { | |
| 782 | + | Op::Loop(body) => 1 + loops(body), | |
| 783 | + | Op::Branch(body) => loops(body), | |
| 784 | + | _ => 0, | |
| 785 | + | }) | |
| 786 | + | .sum() | |
| 787 | + | } | |
| 788 | + | assert_eq!(loops(CREATORS.ops()), 0, "the tiers are unrolled"); | |
| 789 | + | } | |
| 790 | + | ||
| 702 | 791 | /// Every screen the roster names is checked above. | |
| 703 | 792 | /// | |
| 704 | 793 | /// The gap this closes is the one a table opens: a screen added to | |
| @@ -714,8 +803,9 @@ | |||
| 714 | 803 | fn every_screen_on_the_seam_is_checked() { | |
| 715 | 804 | /// Screens with their own filling test: `/use-cases`, `/fan-plus`, | |
| 716 | 805 | /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the | |
| 717 | - | /// two forum panes, the two contact panes, and the payout card. | |
| 718 | - | const HOLED: usize = 10; | |
| 806 | + | /// two forum panes, the two contact panes, the payout card and | |
| 807 | + | /// `/creators`. | |
| 808 | + | const HOLED: usize = 11; | |
| 719 | 809 | ||
| 720 | 810 | assert_eq!( | |
| 721 | 811 | roster().len(), |
| @@ -263,9 +263,7 @@ | |||
| 263 | 263 | region REGION as Pane { | |
| 264 | 264 | section super::range_heading(&analytics.range); | |
| 265 | 265 | ||
| 266 | - | for chip in range_chips(&analytics.range) { | |
| 267 | - | include chip; | |
| 268 | - | } | |
| 266 | + | include each range_chips(&analytics.range); | |
| 269 | 267 | ||
| 270 | 268 | include stats(&analytics.stats); | |
| 271 | 269 |
| @@ -419,4 +419,58 @@ | |||
| 419 | 419 | ])), | |
| 420 | 420 | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"payout-summary-section-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 421 | 421 | ]); | |
| 422 | + | ||
| 423 | + | pub static CREATORS: ::quasi_router::stage::Residual = | |
| 424 | + | ::quasi_router::stage::Residual::compiled(&[ | |
| 425 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"creators\" class=\"region pane\"><h1 class=\"heading\">Become a Creator</h1><p class=\"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.</p><h2 class=\"heading\">How It Works</h2><div class=\"rich\" data-disable-scripting><ol>\n<li><strong>Sign up</strong> and verify your email</li>\n<li><strong>Apply</strong> from your dashboard: tell us what you make and which tier fits</li>\n<li><strong>Get approved</strong>: we review applications individually, usually within a few days</li>\n</ol>\n<p>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.</p>\n<p><strong>Important:</strong> You sell in the currency your Stripe account settles in, and receiving payouts requires a <a href=\"https://stripe.com/global\" rel=\"noopener noreferrer\">Stripe</a> account in a supported country that settles in one of the six we support: <strong>USD, CAD, GBP, AUD, NZD or EUR</strong>. Check both with Stripe before applying.</p>\n</div><div class=\"figures\"><div class=\"figure\" aria-label=\"Active Creators: ")), | |
| 426 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 0 }, | |
| 427 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><span class=\"figure-value\" aria-hidden=\"true\">")), | |
| 428 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 0 }, | |
| 429 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span><span class=\"figure-caption\" aria-hidden=\"true\">Active Creators</span></div></div><h2 class=\"heading\">Pricing</h2><p class=\"text\">Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is the payment processor's fee (~3%).</p><div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Tier cell-content cell-keeps\">Tier</span><span role=\"columnheader\" class=\"table-heading col-Monthly cell-content cell-drops-next\">Monthly</span><span role=\"columnheader\" class=\"table-heading col-Best-For cell-fill cell-drops-next\">Best For</span><span role=\"columnheader\" class=\"table-heading col-Storage cell-content cell-drops-next\">Storage</span></div><div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Tier cell-content cell-keeps cell-value\">Basic</div><div class=\"cell col-Monthly cell-content cell-drops-next cell-value\">")), | |
| 430 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 0 }, | |
| 431 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Best-For cell-fill cell-drops-next cell-value\">Text, blogs, newsletters</div><div class=\"cell col-Storage cell-content cell-drops-next cell-value\">")), | |
| 432 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 1 }, | |
| 433 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div><div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Tier cell-content cell-keeps cell-value\">Small Files</div><div class=\"cell col-Monthly cell-content cell-drops-next cell-value\">")), | |
| 434 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 2 }, | |
| 435 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Best-For cell-fill cell-drops-next cell-value\">Audio, plugins, small software</div><div class=\"cell col-Storage cell-content cell-drops-next cell-value\">")), | |
| 436 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 3 }, | |
| 437 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div><div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Tier cell-content cell-keeps cell-value\">Big Files</div><div class=\"cell col-Monthly cell-content cell-drops-next cell-value\">")), | |
| 438 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 4 }, | |
| 439 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Best-For cell-fill cell-drops-next cell-value\">Video, games, large software</div><div class=\"cell col-Storage cell-content cell-drops-next cell-value\">")), | |
| 440 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 5 }, | |
| 441 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div><div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Tier cell-content cell-keeps cell-value\">Everything</div><div class=\"cell col-Monthly cell-content cell-drops-next cell-value\">")), | |
| 442 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 6 }, | |
| 443 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Best-For cell-fill cell-drops-next cell-value\">All features, current and future</div><div class=\"cell col-Storage cell-content cell-drops-next cell-value\">")), | |
| 444 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 7 }, | |
| 445 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div></div><div class=\"rich\" data-disable-scripting><p>Every tier is the complete platform: <code>/u/username</code> 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 <a href=\"https://stripe.com/global\" rel=\"noopener noreferrer\">Stripe</a> in a supported country. <a href=\"/docs/tiers\" rel=\"noopener noreferrer\">Full tier details</a> | <a href=\"/docs/pricing\" rel=\"noopener noreferrer\">Pricing models</a></p>\n</div><div class=\"rich\" data-disable-scripting><p><strong>Not ready to commit?</strong> Request a <strong>free trial</strong> (2-6 weeks, no credit card) when you apply. Or <a href=\"/sandbox\" rel=\"noopener noreferrer\">try sandbox mode</a> to explore the dashboard without signing up.</p>\n</div><h2 class=\"heading\">Who Runs This</h2><div class=\"rich\" data-disable-scripting><p>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 <a href=\"/docs/guarantees#continuity\" rel=\"noopener noreferrer\">continuity guarantee</a> and <a href=\"/docs/economics\" rel=\"noopener noreferrer\">platform economics</a>.</p>\n</div>")), | |
| 446 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 447 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">You have creator access.</p>")), | |
| 448 | + | ||
| 449 | + | ])), | |
| 450 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 451 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/dashboard\">Go to Dashboard</a>")), | |
| 452 | + | ||
| 453 | + | ])), | |
| 454 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 455 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Ready to create?</p>")), | |
| 456 | + | ||
| 457 | + | ])), | |
| 458 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 459 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/dashboard?tab=settings&section=creator\">Apply from Dashboard</a>")), | |
| 460 | + | ||
| 461 | + | ])), | |
| 462 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 463 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Join to get started.</p>")), | |
| 464 | + | ||
| 465 | + | ])), | |
| 466 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 467 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/join\">Join</a>")), | |
| 468 | + | ||
| 469 | + | ])), | |
| 470 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 471 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/login\">Login</a>")), | |
| 472 | + | ||
| 473 | + | ])), | |
| 474 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"creators-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 475 | + | ]); | |
| 422 | 476 |
| @@ -1,0 +1,33 @@ | |||
| 1 | + | # The tier table at `/creators`, which is words and a tier name. | |
| 2 | + | # | |
| 3 | + | # Read at build time by the `declare!` in `src/quasi/creators.rs`. Editing this | |
| 4 | + | # file is editing the page. See `content/use-cases.toml` for the split this | |
| 5 | + | # follows, including the trade it makes. | |
| 6 | + | # | |
| 7 | + | # `priced` names which tier's figures the row ends with, and that is the one | |
| 8 | + | # thing on this table a request decides: the numbers come from `TierPrices` in | |
| 9 | + | # memory, so the last two cells are holes and the first two are literals. The | |
| 10 | + | # word here is a tier name and not a price, which is what keeps a stale number | |
| 11 | + | # from ever being written down. | |
| 12 | + | # | |
| 13 | + | # The order is the order the shipped table listed them. | |
| 14 | + | ||
| 15 | + | [[tiers]] | |
| 16 | + | name = "Basic" | |
| 17 | + | best_for = "Text, blogs, newsletters" | |
| 18 | + | priced = "basic" | |
| 19 | + | ||
| 20 | + | [[tiers]] | |
| 21 | + | name = "Small Files" | |
| 22 | + | best_for = "Audio, plugins, small software" | |
| 23 | + | priced = "small-files" | |
| 24 | + | ||
| 25 | + | [[tiers]] | |
| 26 | + | name = "Big Files" | |
| 27 | + | best_for = "Video, games, large software" | |
| 28 | + | priced = "big-files" | |
| 29 | + | ||
| 30 | + | [[tiers]] | |
| 31 | + | name = "Everything" | |
| 32 | + | best_for = "All features, current and future" | |
| 33 | + | priced = "everything" |