max / makenotwork
15 files changed,
+273 insertions,
-75 deletions
| @@ -4140,7 +4140,7 @@ dependencies = [ | |||
| 4140 | 4140 | ||
| 4141 | 4141 | [[package]] | |
| 4142 | 4142 | name = "makenotwork" | |
| 4143 | - | version = "0.8.14" | |
| 4143 | + | version = "0.8.15" | |
| 4144 | 4144 | dependencies = [ | |
| 4145 | 4145 | "anyhow", | |
| 4146 | 4146 | "apple-codesign", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.8.14" | |
| 3 | + | version = "0.8.15" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -33,6 +33,7 @@ pub mod scanning; | |||
| 33 | 33 | pub mod storage; | |
| 34 | 34 | pub mod synckit_auth; | |
| 35 | 35 | pub mod templates; | |
| 36 | + | pub mod tier_prices; | |
| 36 | 37 | pub mod types; | |
| 37 | 38 | pub mod validation; | |
| 38 | 39 | pub mod wordlist; | |
| @@ -73,6 +74,7 @@ pub struct AppState { | |||
| 73 | 74 | pub stripe: Option<Arc<dyn PaymentProvider>>, | |
| 74 | 75 | pub email: EmailClient, | |
| 75 | 76 | pub docs: Arc<DocLoader>, | |
| 77 | + | pub tier_prices: tier_prices::TierPrices, | |
| 76 | 78 | pub scanner: Option<Arc<ScanPipeline>>, | |
| 77 | 79 | pub webauthn: Arc<Webauthn>, | |
| 78 | 80 | pub syntax: Option<Arc<git::SyntaxHighlighter>>, |
| @@ -301,6 +301,7 @@ async fn main() { | |||
| 301 | 301 | stripe, | |
| 302 | 302 | email, | |
| 303 | 303 | docs, | |
| 304 | + | tier_prices: makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions), | |
| 304 | 305 | scanner, | |
| 305 | 306 | webauthn, | |
| 306 | 307 | syntax, |
| @@ -159,6 +159,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator( | |||
| 159 | 159 | founder_window_open: state.config.creator_founder_window_open, | |
| 160 | 160 | is_founder: db_user.is_founder, | |
| 161 | 161 | is_founder_locked: db_user.is_founder_locked(), | |
| 162 | + | tier_cards: state.tier_prices.cards(), | |
| 162 | 163 | }) | |
| 163 | 164 | } | |
| 164 | 165 |
| @@ -64,6 +64,7 @@ pub(super) async fn index( | |||
| 64 | 64 | total_items: total_items as u32, | |
| 65 | 65 | founder_window_open, | |
| 66 | 66 | founder_slots_remaining, | |
| 67 | + | tier_prices: state.tier_prices.clone(), | |
| 67 | 68 | }.into_response()) | |
| 68 | 69 | } | |
| 69 | 70 | } | |
| @@ -348,9 +349,13 @@ pub(crate) async fn login_page(session: Session) -> impl IntoResponse { | |||
| 348 | 349 | ||
| 349 | 350 | /// Render the interactive pricing calculator page. | |
| 350 | 351 | #[tracing::instrument(skip_all, name = "landing::pricing_page")] | |
| 351 | - | pub(super) async fn pricing_page(session: Session) -> impl IntoResponse { | |
| 352 | + | pub(super) async fn pricing_page( | |
| 353 | + | State(state): State<AppState>, | |
| 354 | + | session: Session, | |
| 355 | + | ) -> impl IntoResponse { | |
| 352 | 356 | PricingTemplate { | |
| 353 | 357 | csrf_token: get_csrf_token(&session).await, | |
| 358 | + | tier_prices: state.tier_prices.clone(), | |
| 354 | 359 | } | |
| 355 | 360 | } | |
| 356 | 361 | ||
| @@ -385,12 +390,14 @@ pub(super) async fn checkout_complete() -> impl IntoResponse { | |||
| 385 | 390 | /// Render the use cases page. | |
| 386 | 391 | #[tracing::instrument(skip_all, name = "landing::use_cases_page")] | |
| 387 | 392 | pub(super) async fn use_cases_page( | |
| 393 | + | State(state): State<AppState>, | |
| 388 | 394 | session: Session, | |
| 389 | 395 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 390 | 396 | ) -> impl IntoResponse { | |
| 391 | 397 | UseCasesTemplate { | |
| 392 | 398 | csrf_token: get_csrf_token(&session).await, | |
| 393 | 399 | session_user: maybe_user, | |
| 400 | + | tier_prices: state.tier_prices.clone(), | |
| 394 | 401 | } | |
| 395 | 402 | } | |
| 396 | 403 |
| @@ -127,5 +127,6 @@ async fn creators_page( | |||
| 127 | 127 | total_creators, | |
| 128 | 128 | waitlist_pending, | |
| 129 | 129 | is_creator, | |
| 130 | + | tier_prices: state.tier_prices.clone(), | |
| 130 | 131 | }) | |
| 131 | 132 | } |
| @@ -335,6 +335,9 @@ pub struct UserCreatorTabTemplate { | |||
| 335 | 335 | pub is_founder: bool, | |
| 336 | 336 | /// Whether this user's founder pricing is permanently locked in. | |
| 337 | 337 | pub is_founder_locked: bool, | |
| 338 | + | /// Tier cards rendered in the upgrade grid. Built from `state.tier_prices` | |
| 339 | + | /// so a price change in `assumptions.toml` flows through automatically. | |
| 340 | + | pub tier_cards: Vec<crate::tier_prices::TierCard>, | |
| 338 | 341 | } | |
| 339 | 342 | ||
| 340 | 343 | /// Dashboard tab: project overview with stat cards. |
| @@ -54,6 +54,7 @@ pub struct IndexTemplate { | |||
| 54 | 54 | /// Remaining founder slots (1,000 cap). Only shown when small enough to | |
| 55 | 55 | /// convey urgency; not exposed when comfortably above the cap. | |
| 56 | 56 | pub founder_slots_remaining: Option<u32>, | |
| 57 | + | pub tier_prices: crate::tier_prices::TierPrices, | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | /// User's library shell with inline purchases tab (other tabs loaded via HTMX). | |
| @@ -766,6 +767,7 @@ pub struct DocIndexTemplate { | |||
| 766 | 767 | #[template(path = "pages/pricing.html")] | |
| 767 | 768 | pub struct PricingTemplate { | |
| 768 | 769 | pub csrf_token: CsrfTokenOption, | |
| 770 | + | pub tier_prices: crate::tier_prices::TierPrices, | |
| 769 | 771 | } | |
| 770 | 772 | ||
| 771 | 773 | /// Use cases page showcasing creator types. | |
| @@ -774,6 +776,7 @@ pub struct PricingTemplate { | |||
| 774 | 776 | pub struct UseCasesTemplate { | |
| 775 | 777 | pub csrf_token: CsrfTokenOption, | |
| 776 | 778 | pub session_user: Option<SessionUser>, | |
| 779 | + | pub tier_prices: crate::tier_prices::TierPrices, | |
| 777 | 780 | } | |
| 778 | 781 | ||
| 779 | 782 | /// Team page listing the founder, residents, and fellows. | |
| @@ -806,6 +809,7 @@ pub struct CreatorsTemplate { | |||
| 806 | 809 | pub total_creators: u32, | |
| 807 | 810 | pub waitlist_pending: u32, | |
| 808 | 811 | pub is_creator: bool, | |
| 812 | + | pub tier_prices: crate::tier_prices::TierPrices, | |
| 809 | 813 | } | |
| 810 | 814 | ||
| 811 | 815 | // ============================================================================ |
| @@ -0,0 +1,190 @@ | |||
| 1 | + | //! Tier prices and storage envelopes pulled from `assumptions.toml` at startup. | |
| 2 | + | //! | |
| 3 | + | //! Templates referencing these via `{{ tier_prices.basic_std }}` etc. stay in | |
| 4 | + | //! sync with the docengine substitution system — both read from the same toml. | |
| 5 | + | //! A price change is a one-line edit to assumptions.toml + a server restart. | |
| 6 | + | //! | |
| 7 | + | //! Missing or wrong-typed keys panic at startup (same pattern as | |
| 8 | + | //! `Assumptions::validate` failure in main.rs). Production never serves with a | |
| 9 | + | //! half-loaded `TierPrices`. | |
| 10 | + | ||
| 11 | + | use docengine::{Assumptions, LookupValue}; | |
| 12 | + | ||
| 13 | + | #[derive(Clone, Debug)] | |
| 14 | + | pub struct TierPrices { | |
| 15 | + | // Standard monthly (post-founder sticker rates). | |
| 16 | + | pub basic_std: i32, | |
| 17 | + | pub small_files_std: i32, | |
| 18 | + | pub big_files_std: i32, | |
| 19 | + | pub everything_std: i32, | |
| 20 | + | // Founder monthly (50% of standard, locked for life when window closes). | |
| 21 | + | pub basic_founder: i32, | |
| 22 | + | pub small_files_founder: i32, | |
| 23 | + | pub big_files_founder: i32, | |
| 24 | + | pub everything_founder: i32, | |
| 25 | + | // Standard annual (monthly × 12 × annual_discount.multiplier, rounded). | |
| 26 | + | pub annual_basic_std: i32, | |
| 27 | + | pub annual_small_files_std: i32, | |
| 28 | + | pub annual_big_files_std: i32, | |
| 29 | + | pub annual_everything_std: i32, | |
| 30 | + | // Founder annual. | |
| 31 | + | pub annual_basic_founder: i32, | |
| 32 | + | pub annual_small_files_founder: i32, | |
| 33 | + | pub annual_big_files_founder: i32, | |
| 34 | + | pub annual_everything_founder: i32, | |
| 35 | + | // Per-file caps and total storage caps, as display strings ("10MB", "50GB"). | |
| 36 | + | pub basic_per_file: String, | |
| 37 | + | pub small_files_per_file: String, | |
| 38 | + | pub big_files_per_file: String, | |
| 39 | + | pub everything_per_file: String, | |
| 40 | + | pub basic_total: String, | |
| 41 | + | pub small_files_total: String, | |
| 42 | + | pub big_files_total: String, | |
| 43 | + | pub everything_total: String, | |
| 44 | + | // Founder cohort cap, display string with thousands separator ("1,000"). | |
| 45 | + | pub cohort_cap_display: String, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl TierPrices { | |
| 49 | + | pub fn from_assumptions(a: &Assumptions) -> Self { | |
| 50 | + | Self { | |
| 51 | + | basic_std: int_at(a, "tiers.standard.basic"), | |
| 52 | + | small_files_std: int_at(a, "tiers.standard.small_files"), | |
| 53 | + | big_files_std: int_at(a, "tiers.standard.big_files"), | |
| 54 | + | everything_std: int_at(a, "tiers.standard.everything"), | |
| 55 | + | basic_founder: int_at(a, "tiers.founding.basic"), | |
| 56 | + | small_files_founder: int_at(a, "tiers.founding.small_files"), | |
| 57 | + | big_files_founder: int_at(a, "tiers.founding.big_files"), | |
| 58 | + | everything_founder: int_at(a, "tiers.founding.everything"), | |
| 59 | + | annual_basic_std: int_at(a, "derived.annual_standard_basic"), | |
| 60 | + | annual_small_files_std: int_at(a, "derived.annual_standard_small_files"), | |
| 61 | + | annual_big_files_std: int_at(a, "derived.annual_standard_big_files"), | |
| 62 | + | annual_everything_std: int_at(a, "derived.annual_standard_everything"), | |
| 63 | + | annual_basic_founder: int_at(a, "derived.annual_founding_basic"), | |
| 64 | + | annual_small_files_founder: int_at(a, "derived.annual_founding_small_files"), | |
| 65 | + | annual_big_files_founder: int_at(a, "derived.annual_founding_big_files"), | |
| 66 | + | annual_everything_founder: int_at(a, "derived.annual_founding_everything"), | |
| 67 | + | basic_per_file: str_at(a, "tier_limits.basic_per_file"), | |
| 68 | + | small_files_per_file: str_at(a, "tier_limits.small_files_per_file"), | |
| 69 | + | big_files_per_file: str_at(a, "tier_limits.big_files_per_file"), | |
| 70 | + | everything_per_file: str_at(a, "tier_limits.everything_per_file"), | |
| 71 | + | basic_total: str_at(a, "tier_limits.basic_total"), | |
| 72 | + | small_files_total: str_at(a, "tier_limits.small_files_total"), | |
| 73 | + | big_files_total: str_at(a, "tier_limits.big_files_total"), | |
| 74 | + | everything_total: str_at(a, "tier_limits.everything_total"), | |
| 75 | + | cohort_cap_display: str_at(a, "cohort.cap_display"), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Display row for the dashboard tier-picker grid (`user_creator.html`). | |
| 81 | + | #[derive(Clone, Debug)] | |
| 82 | + | pub struct TierCard { | |
| 83 | + | pub key: &'static str, | |
| 84 | + | pub label: &'static str, | |
| 85 | + | pub storage: String, | |
| 86 | + | pub founder_monthly: i32, | |
| 87 | + | pub standard_monthly: i32, | |
| 88 | + | pub founder_annual: i32, | |
| 89 | + | pub standard_annual: i32, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | impl TierPrices { | |
| 93 | + | /// Build the four tier cards the dashboard renders. Order matters | |
| 94 | + | /// (Basic, Small Files, Big Files, Everything) — it's the canonical | |
| 95 | + | /// presentation order. | |
| 96 | + | pub fn cards(&self) -> Vec<TierCard> { | |
| 97 | + | vec![ | |
| 98 | + | TierCard { | |
| 99 | + | key: "basic", | |
| 100 | + | label: "Basic", | |
| 101 | + | storage: format!("{}, {}/file", self.basic_total, self.basic_per_file), | |
| 102 | + | founder_monthly: self.basic_founder, | |
| 103 | + | standard_monthly: self.basic_std, | |
| 104 | + | founder_annual: self.annual_basic_founder, | |
| 105 | + | standard_annual: self.annual_basic_std, | |
| 106 | + | }, | |
| 107 | + | TierCard { | |
| 108 | + | key: "small_files", | |
| 109 | + | label: "Small Files", | |
| 110 | + | storage: format!("{}, {}/file", self.small_files_total, self.small_files_per_file), | |
| 111 | + | founder_monthly: self.small_files_founder, | |
| 112 | + | standard_monthly: self.small_files_std, | |
| 113 | + | founder_annual: self.annual_small_files_founder, | |
| 114 | + | standard_annual: self.annual_small_files_std, | |
| 115 | + | }, | |
| 116 | + | TierCard { | |
| 117 | + | key: "big_files", | |
| 118 | + | label: "Big Files", | |
| 119 | + | storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file), | |
| 120 | + | founder_monthly: self.big_files_founder, | |
| 121 | + | standard_monthly: self.big_files_std, | |
| 122 | + | founder_annual: self.annual_big_files_founder, | |
| 123 | + | standard_annual: self.annual_big_files_std, | |
| 124 | + | }, | |
| 125 | + | TierCard { | |
| 126 | + | key: "everything", | |
| 127 | + | label: "Everything", | |
| 128 | + | storage: format!( | |
| 129 | + | "{}, {}/file, all features", | |
| 130 | + | self.everything_total, self.everything_per_file | |
| 131 | + | ), | |
| 132 | + | founder_monthly: self.everything_founder, | |
| 133 | + | standard_monthly: self.everything_std, | |
| 134 | + | founder_annual: self.annual_everything_founder, | |
| 135 | + | standard_annual: self.annual_everything_std, | |
| 136 | + | }, | |
| 137 | + | ] | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn int_at(a: &Assumptions, key: &str) -> i32 { | |
| 142 | + | match a.get(key) { | |
| 143 | + | Some(LookupValue::Int(n)) => i32::try_from(*n) | |
| 144 | + | .unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32")), | |
| 145 | + | Some(LookupValue::Float(x)) => x.round() as i32, | |
| 146 | + | other => panic!("expected integer at {key}, got {other:?}"), | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | fn str_at(a: &Assumptions, key: &str) -> String { | |
| 151 | + | match a.get(key) { | |
| 152 | + | Some(LookupValue::String(s)) => s.clone(), | |
| 153 | + | other => panic!("expected string at {key}, got {other:?}"), | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[cfg(test)] | |
| 158 | + | mod tests { | |
| 159 | + | use super::*; | |
| 160 | + | ||
| 161 | + | const ASSUMPTIONS_PATH: &str = | |
| 162 | + | "../../_private/docs/mnw/server-internal/business/assumptions.toml"; | |
| 163 | + | ||
| 164 | + | /// Guards every key TierPrices reads. If a future toml edit removes one of | |
| 165 | + | /// these or flips its type, the panic in `from_assumptions` will fire at | |
| 166 | + | /// startup; this test catches it at PR time instead. | |
| 167 | + | #[test] | |
| 168 | + | fn from_canonical_assumptions_populates_every_field() { | |
| 169 | + | let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); | |
| 170 | + | let p = TierPrices::from_assumptions(&a); | |
| 171 | + | ||
| 172 | + | // Sanity-check sentinels (values match the toml; if they change in | |
| 173 | + | // toml, update here too). | |
| 174 | + | assert_eq!(p.basic_std, 16); | |
| 175 | + | assert_eq!(p.everything_std, 60); | |
| 176 | + | assert_eq!(p.basic_founder, 8); | |
| 177 | + | assert_eq!(p.annual_basic_std, 173); | |
| 178 | + | assert_eq!(p.annual_everything_founder, 324); | |
| 179 | + | assert_eq!(p.basic_per_file, "10MB"); | |
| 180 | + | assert_eq!(p.everything_total, "500GB"); | |
| 181 | + | assert_eq!(p.cohort_cap_display, "1,000"); | |
| 182 | + | ||
| 183 | + | // Cards iteration produces the four canonical rows. | |
| 184 | + | let cards = p.cards(); | |
| 185 | + | assert_eq!(cards.len(), 4); | |
| 186 | + | assert_eq!(cards[0].key, "basic"); | |
| 187 | + | assert_eq!(cards[3].key, "everything"); | |
| 188 | + | assert_eq!(cards[1].standard_monthly, 24); | |
| 189 | + | } | |
| 190 | + | } |
| @@ -57,27 +57,27 @@ | |||
| 57 | 57 | <tbody> | |
| 58 | 58 | <tr> | |
| 59 | 59 | <td>Basic</td> | |
| 60 | - | <td>$16</td> | |
| 60 | + | <td>${{ tier_prices.basic_std }}</td> | |
| 61 | 61 | <td>Text, blogs, newsletters</td> | |
| 62 | - | <td>50 GB</td> | |
| 62 | + | <td>{{ tier_prices.basic_total }}</td> | |
| 63 | 63 | </tr> | |
| 64 | 64 | <tr> | |
| 65 | 65 | <td>Small Files</td> | |
| 66 | - | <td>$24</td> | |
| 66 | + | <td>${{ tier_prices.small_files_std }}</td> | |
| 67 | 67 | <td>Audio, plugins, small software</td> | |
| 68 | - | <td>250 GB</td> | |
| 68 | + | <td>{{ tier_prices.small_files_total }}</td> | |
| 69 | 69 | </tr> | |
| 70 | 70 | <tr> | |
| 71 | 71 | <td>Big Files</td> | |
| 72 | - | <td>$36</td> | |
| 72 | + | <td>${{ tier_prices.big_files_std }}</td> | |
| 73 | 73 | <td>Video, games, large software</td> | |
| 74 | - | <td>500 GB</td> | |
| 74 | + | <td>{{ tier_prices.big_files_total }}</td> | |
| 75 | 75 | </tr> | |
| 76 | 76 | <tr> | |
| 77 | 77 | <td>Everything</td> | |
| 78 | - | <td>$60</td> | |
| 78 | + | <td>${{ tier_prices.everything_std }}</td> | |
| 79 | 79 | <td>All features, current and future</td> | |
| 80 | - | <td>500 GB</td> | |
| 80 | + | <td>{{ tier_prices.everything_total }}</td> | |
| 81 | 81 | </tr> | |
| 82 | 82 | </tbody> | |
| 83 | 83 | </table> |
| @@ -37,19 +37,19 @@ | |||
| 37 | 37 | <div class="founder-tier-grid" aria-label="Founder tier pricing"> | |
| 38 | 38 | <div class="founder-tier"> | |
| 39 | 39 | <span class="founder-tier-name">Basic</span> | |
| 40 | - | <span class="founder-tier-price"><del>$16</del> <strong>$8</strong><span class="founder-tier-unit">/mo</span></span> | |
| 40 | + | <span class="founder-tier-price"><del>${{ tier_prices.basic_std }}</del> <strong>${{ tier_prices.basic_founder }}</strong><span class="founder-tier-unit">/mo</span></span> | |
| 41 | 41 | </div> | |
| 42 | 42 | <div class="founder-tier"> | |
| 43 | 43 | <span class="founder-tier-name">Small Files</span> | |
| 44 | - | <span class="founder-tier-price"><del>$24</del> <strong>$12</strong><span class="founder-tier-unit">/mo</span></span> | |
| 44 | + | <span class="founder-tier-price"><del>${{ tier_prices.small_files_std }}</del> <strong>${{ tier_prices.small_files_founder }}</strong><span class="founder-tier-unit">/mo</span></span> | |
| 45 | 45 | </div> | |
| 46 | 46 | <div class="founder-tier"> | |
| 47 | 47 | <span class="founder-tier-name">Big Files</span> | |
| 48 | - | <span class="founder-tier-price"><del>$36</del> <strong>$18</strong><span class="founder-tier-unit">/mo</span></span> | |
| 48 | + | <span class="founder-tier-price"><del>${{ tier_prices.big_files_std }}</del> <strong>${{ tier_prices.big_files_founder }}</strong><span class="founder-tier-unit">/mo</span></span> | |
| 49 | 49 | </div> | |
| 50 | 50 | <div class="founder-tier"> | |
| 51 | 51 | <span class="founder-tier-name">Everything</span> | |
| 52 | - | <span class="founder-tier-price"><del>$60</del> <strong>$30</strong><span class="founder-tier-unit">/mo</span></span> | |
| 52 | + | <span class="founder-tier-price"><del>${{ tier_prices.everything_std }}</del> <strong>${{ tier_prices.everything_founder }}</strong><span class="founder-tier-unit">/mo</span></span> | |
| 53 | 53 | </div> | |
| 54 | 54 | </div> | |
| 55 | 55 | {% if let Some(remaining) = founder_slots_remaining %} | |
| @@ -67,7 +67,7 @@ | |||
| 67 | 67 | ||
| 68 | 68 | <ul class="fork-list"> | |
| 69 | 69 | {% if !founder_window_open %} | |
| 70 | - | <li><strong>Flat monthly pricing</strong>: starts at $16/mo</li> | |
| 70 | + | <li><strong>Flat monthly pricing</strong>: starts at ${{ tier_prices.basic_std }}/mo</li> | |
| 71 | 71 | {% endif %} | |
| 72 | 72 | <li><strong>0% platform fee</strong>: only ~3% payment processing</li> | |
| 73 | 73 | <li><strong>Every tier is the complete platform</strong>: profile, forum, discovery, memberships. Tier picks the file-size envelope, not the feature set</li> | |
| @@ -112,7 +112,7 @@ | |||
| 112 | 112 | <ul class="how-list"> | |
| 113 | 113 | <li><strong>No investors, no ads</strong>: funded by subscriptions, not surveillance.</li> | |
| 114 | 114 | <li><strong>No tracking</strong>: no banners, no pixels, no sponsored placements.</li> | |
| 115 | - | <li><strong>Cheaper at scale</strong>: at $50k/mo, a 10% platform takes $5,000. We take a flat tier fee ($16–$60).</li> | |
| 115 | + | <li><strong>Cheaper at scale</strong>: at $50k/mo, a 10% platform takes $5,000. We take a flat tier fee (${{ tier_prices.basic_std }}–${{ tier_prices.everything_std }}).</li> | |
| 116 | 116 | <li><strong>Built to last</strong>: no debt, no growth mandates, no reason to enshittify.</li> | |
| 117 | 117 | </ul> | |
| 118 | 118 | <a class="section-link" href="/docs/how-we-work">How the business model works</a> |
| @@ -23,27 +23,27 @@ | |||
| 23 | 23 | <p class="tier-intro">Every tier is the complete platform: profile, project pages, forum, discovery, memberships, analytics, full data export. The tier picks the file-size envelope, not the feature set.</p> | |
| 24 | 24 | <div class="tier-selector" role="radiogroup" aria-label="Content tier"> | |
| 25 | 25 | <label class="tier-card tier-option is-selected"> | |
| 26 | - | <input type="radio" name="tier" value="16" checked> | |
| 26 | + | <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked> | |
| 27 | 27 | <div class="tier-name">Basic</div> | |
| 28 | - | <div class="tier-price">$16/mo</div> | |
| 29 | - | <div class="tier-desc">10MB/file, 50GB total. Fits text, blogs, newsletters.</div> | |
| 28 | + | <div class="tier-price">${{ tier_prices.basic_std }}/mo</div> | |
| 29 | + | <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div> | |
| 30 | 30 | </label> | |
| 31 | 31 | <label class="tier-card tier-option"> | |
| 32 | - | <input type="radio" name="tier" value="24"> | |
| 32 | + | <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}"> | |
| 33 | 33 | <div class="tier-name">Small Files</div> | |
| 34 | - | <div class="tier-price">$24/mo</div> | |
| 35 | - | <div class="tier-desc">500MB/file, 250GB total. Fits audio, plugins, binaries.</div> | |
| 34 | + | <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div> | |
| 35 | + | <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div> | |
| 36 | 36 | </label> | |
| 37 | 37 | <label class="tier-card tier-option"> | |
| 38 | - | <input type="radio" name="tier" value="36"> | |
| 38 | + | <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}"> | |
| 39 | 39 | <div class="tier-name">Big Files</div> | |
| 40 | - | <div class="tier-price">$36/mo</div> | |
| 41 | - | <div class="tier-desc">20GB/file, 500GB total. Fits video, games, large software.</div> | |
| 40 | + | <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div> | |
| 41 | + | <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div> | |
| 42 | 42 | </label> | |
| 43 | 43 | <label class="tier-card tier-option"> | |
| 44 | - | <input type="radio" name="tier" value="60"> | |
| 44 | + | <input type="radio" name="tier" value="{{ tier_prices.everything_std }}"> | |
| 45 | 45 | <div class="tier-name">Everything</div> | |
| 46 | - | <div class="tier-price">$60/mo</div> | |
| 46 | + | <div class="tier-price">${{ tier_prices.everything_std }}/mo</div> | |
| 47 | 47 | <div class="tier-desc">Big Files envelope plus first access to high-cost features as they ship.</div> | |
| 48 | 48 | </label> | |
| 49 | 49 | </div> | |
| @@ -53,7 +53,7 @@ | |||
| 53 | 53 | <h2 class="section-label">You keep</h2> | |
| 54 | 54 | <div class="mnw-summary"> | |
| 55 | 55 | <div class="mnw-keep" id="mnw-keep">$943.00</div> | |
| 56 | - | <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and $16/mo membership</div> | |
| 56 | + | <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and ${{ tier_prices.basic_std }}/mo membership</div> | |
| 57 | 57 | </div> | |
| 58 | 58 | </div> | |
| 59 | 59 | ||
| @@ -83,10 +83,10 @@ | |||
| 83 | 83 | <h2 class="section-label">Where your tier fee goes</h2> | |
| 84 | 84 | <p class="cost-allocation-intro">Per typical creator, monthly. These are platform-wide budget allocations, not per-account accounting. Hover a segment for details.</p> | |
| 85 | 85 | ||
| 86 | - | {# Basic — $16 = 0.76 + 0.40 + 5.00 + 6.00 + 2.00 + 1.84 #} | |
| 86 | + | {# Basic — $16 = 0.76 + 0.40 + 5.00 + 6.00 + 2.00 + 1.84. Sub-amounts hardcoded; see launchplan §4.5.1 for the planned [cost_allocation] toml section. #} | |
| 87 | 87 | <div class="cost-row"> | |
| 88 | - | <div class="cost-row-label"><strong>Basic</strong>$16/mo</div> | |
| 89 | - | <div class="cost-bar" role="img" aria-label="Basic tier $16/mo allocation: Stripe $0.76, Storage $0.40, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $1.84"> | |
| 88 | + | <div class="cost-row-label"><strong>Basic</strong>${{ tier_prices.basic_std }}/mo</div> | |
| 89 | + | <div class="cost-bar" role="img" aria-label="Basic tier ${{ tier_prices.basic_std }}/mo allocation: Stripe $0.76, Storage $0.40, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $1.84"> | |
| 90 | 90 | <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 76" title="Stripe processing: $0.76. Stripe's fee on your monthly subscription.">$0.76</div> | |
| 91 | 91 | <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 40" title="Storage: $0.40. Object storage at your tier's typical fill, on Hetzner.">$0.40</div> | |
| 92 | 92 | <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00. Identity recovery, billing disputes, moderation, abuse, and legal — the work that can't be automated.">$5.00</div> | |
| @@ -98,8 +98,8 @@ | |||
| 98 | 98 | ||
| 99 | 99 | {# Small Files — $24 = 1.00 + 0.60 + 5.00 + 6.00 + 2.00 + 9.40 #} | |
| 100 | 100 | <div class="cost-row"> | |
| 101 | - | <div class="cost-row-label"><strong>Small Files</strong>$24/mo</div> | |
| 102 | - | <div class="cost-bar" role="img" aria-label="Small Files tier $24/mo allocation: Stripe $1.00, Storage $0.60, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $9.40"> | |
| 101 | + | <div class="cost-row-label"><strong>Small Files</strong>${{ tier_prices.small_files_std }}/mo</div> | |
| 102 | + | <div class="cost-bar" role="img" aria-label="Small Files tier ${{ tier_prices.small_files_std }}/mo allocation: Stripe $1.00, Storage $0.60, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $9.40"> | |
| 103 | 103 | <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 100" title="Stripe processing: $1.00.">$1.00</div> | |
| 104 | 104 | <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 60" title="Storage: $0.60. Object storage at your tier's typical fill.">$0.60</div> | |
| 105 | 105 | <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div> | |
| @@ -111,8 +111,8 @@ | |||
| 111 | 111 | ||
| 112 | 112 | {# Big Files — $36 = 1.34 + 0.90 + 5.00 + 6.00 + 2.00 + 20.76 #} | |
| 113 | 113 | <div class="cost-row"> | |
| 114 | - | <div class="cost-row-label"><strong>Big Files</strong>$36/mo</div> | |
| 115 | - | <div class="cost-bar" role="img" aria-label="Big Files tier $36/mo allocation: Stripe $1.34, Storage $0.90, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $20.76"> | |
| 114 | + | <div class="cost-row-label"><strong>Big Files</strong>${{ tier_prices.big_files_std }}/mo</div> | |
| 115 | + | <div class="cost-bar" role="img" aria-label="Big Files tier ${{ tier_prices.big_files_std }}/mo allocation: Stripe $1.34, Storage $0.90, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $20.76"> | |
| 116 | 116 | <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 134" title="Stripe processing: $1.34.">$1.34</div> | |
| 117 | 117 | <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 90" title="Storage: $0.90.">$0.90</div> | |
| 118 | 118 | <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div> | |
| @@ -124,8 +124,8 @@ | |||
| 124 | 124 | ||
| 125 | 125 | {# Everything — $60 = 2.04 + 1.50 + 5.00 + 6.00 + 2.00 + 43.46 #} | |
| 126 | 126 | <div class="cost-row"> | |
| 127 | - | <div class="cost-row-label"><strong>Everything</strong>$60/mo</div> | |
| 128 | - | <div class="cost-bar" role="img" aria-label="Everything tier $60/mo allocation: Stripe $2.04, Storage $1.50, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $43.46"> | |
| 127 | + | <div class="cost-row-label"><strong>Everything</strong>${{ tier_prices.everything_std }}/mo</div> | |
| 128 | + | <div class="cost-bar" role="img" aria-label="Everything tier ${{ tier_prices.everything_std }}/mo allocation: Stripe $2.04, Storage $1.50, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $43.46"> | |
| 129 | 129 | <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 204" title="Stripe processing: $2.04.">$2.04</div> | |
| 130 | 130 | <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 150" title="Storage: $1.50.">$1.50</div> | |
| 131 | 131 | <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div> | |
| @@ -291,7 +291,8 @@ | |||
| 291 | 291 | var revenue = parseFloat(revenueInput.value) || 0; | |
| 292 | 292 | ||
| 293 | 293 | var tierRadios = document.querySelectorAll('input[name="tier"]'); | |
| 294 | - | var tierCost = 16; | |
| 294 | + | // Default falls back to the Basic-tier radio value; templates render the canonical price. | |
| 295 | + | var tierCost = {{ tier_prices.basic_std }}; | |
| 295 | 296 | for (var i = 0; i < tierRadios.length; i++) { | |
| 296 | 297 | if (tierRadios[i].checked) { | |
| 297 | 298 | tierCost = parseInt(tierRadios[i].value, 10); |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | <li>RSS feed per project</li> | |
| 25 | 25 | <li>Pay-what-you-want pricing</li> | |
| 26 | 26 | </ul> | |
| 27 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 27 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 28 | 28 | </div> | |
| 29 | 29 | ||
| 30 | 30 | <div class="use-case-card"> | |
| @@ -37,7 +37,7 @@ | |||
| 37 | 37 | <li>Subscriber-only feeds</li> | |
| 38 | 38 | <li>Broadcast emails to followers</li> | |
| 39 | 39 | </ul> | |
| 40 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 40 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 41 | 41 | </div> | |
| 42 | 42 | ||
| 43 | 43 | <div class="use-case-card"> | |
| @@ -51,7 +51,7 @@ | |||
| 51 | 51 | <li>Scheduled publishing</li> | |
| 52 | 52 | <li>Broadcast emails</li> | |
| 53 | 53 | </ul> | |
| 54 | - | <div class="use-case-tier">Basic $16/mo · 50GB, 10MB/file</div> | |
| 54 | + | <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo · {{ tier_prices.basic_total }}, {{ tier_prices.basic_per_file }}/file</div> | |
| 55 | 55 | </div> | |
| 56 | 56 | ||
| 57 | 57 | <div class="use-case-card"> | |
| @@ -64,7 +64,7 @@ | |||
| 64 | 64 | <li>Git source browser</li> | |
| 65 | 65 | <li>Promo codes and discounts</li> | |
| 66 | 66 | </ul> | |
| 67 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 67 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 68 | 68 | </div> | |
| 69 | 69 | ||
| 70 | 70 | <div class="use-case-card"> | |
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | <li>Pay-what-you-want pricing</li> | |
| 79 | 79 | <li>Cover art</li> | |
| 80 | 80 | </ul> | |
| 81 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 81 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 82 | 82 | </div> | |
| 83 | 83 | ||
| 84 | 84 | <div class="use-case-card"> | |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | <li>Subscription tiers</li> | |
| 92 | 92 | <li>Cover images</li> | |
| 93 | 93 | </ul> | |
| 94 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 94 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 95 | 95 | </div> | |
| 96 | 96 | ||
| 97 | 97 | <div class="use-case-card"> | |
| @@ -104,7 +104,7 @@ | |||
| 104 | 104 | <li>Subscription tiers</li> | |
| 105 | 105 | <li>Scheduled publishing</li> | |
| 106 | 106 | </ul> | |
| 107 | - | <div class="use-case-tier">Basic $16/mo or Small Files $24/mo</div> | |
| 107 | + | <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo or Small Files ${{ tier_prices.small_files_std }}/mo</div> | |
| 108 | 108 | </div> | |
| 109 | 109 | ||
| 110 | 110 | <div class="use-case-card"> | |
| @@ -117,7 +117,7 @@ | |||
| 117 | 117 | <li>License keys with activation limits</li> | |
| 118 | 118 | <li>Promo codes</li> | |
| 119 | 119 | </ul> | |
| 120 | - | <div class="use-case-tier">Big Files $36/mo · 500GB, 20GB/file</div> | |
| 120 | + | <div class="use-case-tier">Big Files ${{ tier_prices.big_files_std }}/mo · {{ tier_prices.big_files_total }}, {{ tier_prices.big_files_per_file }}/file</div> | |
| 121 | 121 | </div> | |
| 122 | 122 | ||
| 123 | 123 | <div class="use-case-card"> | |
| @@ -130,7 +130,7 @@ | |||
| 130 | 130 | <li>Subscriptions</li> | |
| 131 | 131 | <li>Scheduled publishing</li> | |
| 132 | 132 | </ul> | |
| 133 | - | <div class="use-case-tier">Small Files $24/mo · 250GB, 500MB/file</div> | |
| 133 | + | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 134 | 134 | </div> | |
| 135 | 135 | </div> | |
| 136 | 136 | </div> | |
| @@ -147,7 +147,7 @@ | |||
| 147 | 147 | <li>Adaptive streaming</li> | |
| 148 | 148 | <li>All existing features included</li> | |
| 149 | 149 | </ul> | |
| 150 | - | <div class="use-case-tier">Big Files $36/mo · 500GB, 20GB/file</div> | |
| 150 | + | <div class="use-case-tier">Big Files ${{ tier_prices.big_files_std }}/mo · {{ tier_prices.big_files_total }}, {{ tier_prices.big_files_per_file }}/file</div> | |
| 151 | 151 | </div> | |
| 152 | 152 | ||
| 153 | 153 | <div class="use-case-card coming-soon"> | |
| @@ -159,7 +159,7 @@ | |||
| 159 | 159 | <li>VOD archives</li> | |
| 160 | 160 | <li>All existing features included</li> | |
| 161 | 161 | </ul> | |
| 162 | - | <div class="use-case-tier">Everything $60/mo · 500GB, all features</div> | |
| 162 | + | <div class="use-case-tier">Everything ${{ tier_prices.everything_std }}/mo · {{ tier_prices.everything_total }}, all features</div> | |
| 163 | 163 | </div> | |
| 164 | 164 | </div> | |
| 165 | 165 | </div> |
| @@ -53,39 +53,27 @@ | |||
| 53 | 53 | {% if creator_tiers_configured %} | |
| 54 | 54 | {% let show_founder_rate = founder_window_open || is_founder_locked %} | |
| 55 | 55 | <div class="creator-tier-grid mb-4"> | |
| 56 | - | {% for card in [ | |
| 57 | - | ("basic", "Basic", "50GB, 10MB/file", 5, 10, 54, 108), | |
| 58 | - | ("small_files", "Small Files", "250GB, 500MB/file", 10, 20, 108, 216), | |
| 59 | - | ("big_files", "Big Files", "500GB, 20GB/file", 15, 30, 162, 324), | |
| 60 | - | ("everything", "Everything", "500GB, 20GB/file, all features", 30, 60, 324, 648), | |
| 61 | - | ] %} | |
| 62 | - | {% let tier_key = card.0 %} | |
| 63 | - | {% let tier_label = card.1 %} | |
| 64 | - | {% let tier_storage = card.2 %} | |
| 65 | - | {% let founder_monthly = card.3 %} | |
| 66 | - | {% let sticker_monthly = card.4 %} | |
| 67 | - | {% let founder_annual = card.5 %} | |
| 68 | - | {% let sticker_annual = card.6 %} | |
| 56 | + | {% for card in tier_cards %} | |
| 69 | 57 | <div class="creator-tier-card"> | |
| 70 | - | <div class="creator-tier-name"><a href="/docs/guide/tiers" class="creator-tier-link">{{ tier_label }}</a></div> | |
| 58 | + | <div class="creator-tier-name"><a href="/docs/guide/tiers" class="creator-tier-link">{{ card.label }}</a></div> | |
| 71 | 59 | {% if show_founder_rate %} | |
| 72 | - | <div class="meta mb-2"><strong>${{ founder_monthly }}/mo</strong> <span class="strike-dim">${{ sticker_monthly }}</span></div> | |
| 73 | - | <div class="meta creator-tier-sub mb-2">or ${{ founder_annual }}/yr (10% off)</div> | |
| 60 | + | <div class="meta mb-2"><strong>${{ card.founder_monthly }}/mo</strong> <span class="strike-dim">${{ card.standard_monthly }}</span></div> | |
| 61 | + | <div class="meta creator-tier-sub mb-2">or ${{ card.founder_annual }}/yr (10% off)</div> | |
| 74 | 62 | {% else %} | |
| 75 | - | <div class="meta mb-2">${{ sticker_monthly }}/mo</div> | |
| 76 | - | <div class="meta creator-tier-sub mb-2">or ${{ sticker_annual }}/yr (10% off)</div> | |
| 63 | + | <div class="meta mb-2">${{ card.standard_monthly }}/mo</div> | |
| 64 | + | <div class="meta creator-tier-sub mb-2">or ${{ card.standard_annual }}/yr (10% off)</div> | |
| 77 | 65 | {% endif %} | |
| 78 | - | <div class="meta creator-tier-storage mb-3">{{ tier_storage }}</div> | |
| 66 | + | <div class="meta creator-tier-storage mb-3">{{ card.storage }}</div> | |
| 79 | 67 | <div class="creator-tier-buttons"> | |
| 80 | 68 | <form method="post" action="/stripe/creator-tier" class="m-0"> | |
| 81 | 69 | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 82 | - | <input type="hidden" name="tier" value="{{ tier_key }}"> | |
| 70 | + | <input type="hidden" name="tier" value="{{ card.key }}"> | |
| 83 | 71 | <input type="hidden" name="interval" value="monthly"> | |
| 84 | 72 | <button type="submit" class="btn-primary creator-tier-btn" data-loading-text="Redirecting to Stripe...">Monthly</button> | |
| 85 | 73 | </form> | |
| 86 | 74 | <form method="post" action="/stripe/creator-tier" class="m-0"> | |
| 87 | 75 | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 88 | - | <input type="hidden" name="tier" value="{{ tier_key }}"> | |
| 76 | + | <input type="hidden" name="tier" value="{{ card.key }}"> | |
| 89 | 77 | <input type="hidden" name="interval" value="annual"> | |
| 90 | 78 | <button type="submit" class="btn-secondary creator-tier-btn" data-loading-text="Redirecting to Stripe...">Annual (save 10%)</button> | |
| 91 | 79 | </form> | |
| @@ -273,10 +261,10 @@ | |||
| 273 | 261 | <label for="preferred-tier">Which tier fits?</label> | |
| 274 | 262 | <select id="preferred-tier" name="preferred_tier"> | |
| 275 | 263 | <option value="">Not sure yet</option> | |
| 276 | - | <option value="basic">Basic ($16/mo): text, blogs, newsletters</option> | |
| 277 | - | <option value="small_files">Small Files ($24/mo): audio, software, plugins</option> | |
| 278 | - | <option value="big_files">Big Files ($36/mo): video, games, large software</option> | |
| 279 | - | <option value="everything">Everything ($60/mo): all features, current and future</option> | |
| 264 | + | <option value="basic">Basic (${{ tier_cards[0].standard_monthly }}/mo): text, blogs, newsletters</option> | |
| 265 | + | <option value="small_files">Small Files (${{ tier_cards[1].standard_monthly }}/mo): audio, software, plugins</option> | |
| 266 | + | <option value="big_files">Big Files (${{ tier_cards[2].standard_monthly }}/mo): video, games, large software</option> | |
| 267 | + | <option value="everything">Everything (${{ tier_cards[3].standard_monthly }}/mo): all features, current and future</option> | |
| 280 | 268 | </select> | |
| 281 | 269 | </div> | |
| 282 | 270 | <div class="form-group"> |