max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+303 insertions,
-9 deletions
| @@ -6297,6 +6297,50 @@ | |||
| 6297 | 6297 | opacity: 0.5; | |
| 6298 | 6298 | } | |
| 6299 | 6299 | ||
| 6300 | + | /* <mnw-price-mode> is a custom element (default display:inline); it wraps the | |
| 6301 | + | toggle and the tier grid, so it has to lay out as a block. */ | |
| 6302 | + | mnw-price-mode { | |
| 6303 | + | display: block; | |
| 6304 | + | } | |
| 6305 | + | ||
| 6306 | + | /* List-vs-founder toggle above the tier cards, rendered only while the founder | |
| 6307 | + | window is open. Segmented control rather than a checkbox: both options are | |
| 6308 | + | real prices the visitor can be shown, and neither is an "off" state. Follows | |
| 6309 | + | .tier-option's pattern of hiding the radio and styling the label off | |
| 6310 | + | :has(:checked), so the selection needs no JS class. */ | |
| 6311 | + | .price-mode { | |
| 6312 | + | display: inline-flex; | |
| 6313 | + | gap: 0; | |
| 6314 | + | margin-bottom: var(--gap-section); | |
| 6315 | + | border: 1px solid var(--border); | |
| 6316 | + | border-radius: var(--radius-control); | |
| 6317 | + | overflow: hidden; | |
| 6318 | + | } | |
| 6319 | + | ||
| 6320 | + | .price-mode-option { | |
| 6321 | + | cursor: pointer; | |
| 6322 | + | font-family: var(--font-mono); | |
| 6323 | + | font-size: var(--text-note); | |
| 6324 | + | padding: var(--gap-bound) var(--gap-section); | |
| 6325 | + | transition: background-color 0.15s ease; | |
| 6326 | + | } | |
| 6327 | + | ||
| 6328 | + | .price-mode-option input[type="radio"] { | |
| 6329 | + | position: absolute; | |
| 6330 | + | opacity: 0; | |
| 6331 | + | pointer-events: none; | |
| 6332 | + | } | |
| 6333 | + | ||
| 6334 | + | .price-mode-option:has(input[type="radio"]:checked) { | |
| 6335 | + | background: var(--action); | |
| 6336 | + | color: var(--content-on-action); | |
| 6337 | + | } | |
| 6338 | + | ||
| 6339 | + | .price-mode-option:focus-within { | |
| 6340 | + | outline: 2px solid var(--focus-ring); | |
| 6341 | + | outline-offset: -2px; | |
| 6342 | + | } | |
| 6343 | + | ||
| 6300 | 6344 | .tier-selector { | |
| 6301 | 6345 | display: grid; | |
| 6302 | 6346 | grid-template-columns: 1fr 1fr; |
| @@ -53,35 +53,71 @@ | |||
| 53 | 53 | </div> | |
| 54 | 54 | </div> | |
| 55 | 55 | ||
| 56 | + | {# Founder disclosure. Same wording as the landing tagline | |
| 57 | + | (index.html), because it is the same offer and a second phrasing | |
| 58 | + | for it would read as a second offer. Both this and the toggle | |
| 59 | + | below are gated: with the window shut the page is what it was | |
| 60 | + | before either existed. #} | |
| 61 | + | {% if founder_window_open %} | |
| 62 | + | <p class="founder-tagline"> | |
| 63 | + | <span class="founder-tagline-mark">Founder pricing open</span> | |
| 64 | + | <span class="founder-tagline-detail">Half off creator tiers, <strong>locked for life</strong>. The calculator is using founder prices.</span> | |
| 65 | + | </p> | |
| 66 | + | {% endif %} | |
| 67 | + | ||
| 56 | 68 | <div class="tier-section"> | |
| 57 | 69 | <h2 class="section-label">Your content tier</h2> | |
| 58 | 70 | <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> | |
| 71 | + | ||
| 72 | + | {# The radio `value` is what hx-include sends, so it is the price | |
| 73 | + | the server computes on. While the window is open it opens on | |
| 74 | + | the founder price: a visitor who can buy at half price should | |
| 75 | + | not be shown their outcome at a price they will not pay. | |
| 76 | + | <mnw-price-mode> swaps between the two data-price-* values in | |
| 77 | + | the DOM and re-fires the existing recompute. Without JS the | |
| 78 | + | toggle is absent and the founder price stands, which is the | |
| 79 | + | right default rather than a broken one. #} | |
| 80 | + | <mnw-price-mode> | |
| 81 | + | {% if founder_window_open %} | |
| 82 | + | <div class="price-mode" role="radiogroup" aria-label="Calculate with"> | |
| 83 | + | <label class="price-mode-option"> | |
| 84 | + | <input type="radio" name="price-mode" value="founder" checked> | |
| 85 | + | <span>Founder price</span> | |
| 86 | + | </label> | |
| 87 | + | <label class="price-mode-option"> | |
| 88 | + | <input type="radio" name="price-mode" value="list"> | |
| 89 | + | <span>List price</span> | |
| 90 | + | </label> | |
| 91 | + | </div> | |
| 92 | + | {% endif %} | |
| 93 | + | ||
| 59 | 94 | <div class="tier-selector" role="radiogroup" aria-label="Content tier"> | |
| 60 | 95 | <label class="card--bordered tier-card tier-option"> | |
| 61 | - | <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked> | |
| 96 | + | <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.basic_founder }}{% else %}{{ tier_prices.basic_std }}{% endif %}" data-price-std="{{ tier_prices.basic_std }}" data-price-founder="{{ tier_prices.basic_founder }}" checked> | |
| 62 | 97 | <div class="tier-name">Basic</div> | |
| 63 | - | <div class="tier-price">${{ tier_prices.basic_std }}/mo</div> | |
| 98 | + | <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.basic_founder }}{% else %}{{ tier_prices.basic_std }}{% endif %}/mo</div> | |
| 64 | 99 | <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div> | |
| 65 | 100 | </label> | |
| 66 | 101 | <label class="card--bordered tier-card tier-option"> | |
| 67 | - | <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}"> | |
| 102 | + | <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.small_files_founder }}{% else %}{{ tier_prices.small_files_std }}{% endif %}" data-price-std="{{ tier_prices.small_files_std }}" data-price-founder="{{ tier_prices.small_files_founder }}"> | |
| 68 | 103 | <div class="tier-name">Small Files</div> | |
| 69 | - | <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div> | |
| 104 | + | <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.small_files_founder }}{% else %}{{ tier_prices.small_files_std }}{% endif %}/mo</div> | |
| 70 | 105 | <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div> | |
| 71 | 106 | </label> | |
| 72 | 107 | <label class="card--bordered tier-card tier-option"> | |
| 73 | - | <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}"> | |
| 108 | + | <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.big_files_founder }}{% else %}{{ tier_prices.big_files_std }}{% endif %}" data-price-std="{{ tier_prices.big_files_std }}" data-price-founder="{{ tier_prices.big_files_founder }}"> | |
| 74 | 109 | <div class="tier-name">Big Files</div> | |
| 75 | - | <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div> | |
| 110 | + | <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.big_files_founder }}{% else %}{{ tier_prices.big_files_std }}{% endif %}/mo</div> | |
| 76 | 111 | <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div> | |
| 77 | 112 | </label> | |
| 78 | 113 | <label class="card--bordered tier-card tier-option"> | |
| 79 | - | <input type="radio" name="tier" value="{{ tier_prices.everything_std }}"> | |
| 114 | + | <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.everything_founder }}{% else %}{{ tier_prices.everything_std }}{% endif %}" data-price-std="{{ tier_prices.everything_std }}" data-price-founder="{{ tier_prices.everything_founder }}"> | |
| 80 | 115 | <div class="tier-name">Everything</div> | |
| 81 | - | <div class="tier-price">${{ tier_prices.everything_std }}/mo</div> | |
| 116 | + | <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.everything_founder }}{% else %}{{ tier_prices.everything_std }}{% endif %}/mo</div> | |
| 82 | 117 | <div class="tier-desc">Big Files envelope plus first access to high-cost features as they ship.</div> | |
| 83 | 118 | </label> | |
| 84 | 119 | </div> | |
| 120 | + | </mnw-price-mode> | |
| 85 | 121 | </div> | |
| 86 | 122 | ||
| 87 | 123 | <div class="tier-section"> |
| @@ -122,6 +122,10 @@ | |||
| 122 | 122 | /// Set `Some(RateLimits::production())` to assert the thresholds that ship, | |
| 123 | 123 | /// which is what `workflows::rate_limiting` does. | |
| 124 | 124 | pub rate_limits: Option<makenotwork::constants::RateLimits>, | |
| 125 | + | /// Whether founder pricing is on offer. Defaults to closed, which is the | |
| 126 | + | /// state every pre-existing test was written against. Set it to exercise | |
| 127 | + | /// the surfaces that only exist while the window is open. | |
| 128 | + | pub founder_window_open: bool, | |
| 125 | 129 | } | |
| 126 | 130 | ||
| 127 | 131 | /// Full test harness: isolated database, in-process app, cookie-aware client. | |
| @@ -169,6 +173,18 @@ | |||
| 169 | 173 | .await | |
| 170 | 174 | } | |
| 171 | 175 | ||
| 176 | + | /// Harness with founder pricing on offer. Every other harness runs with the | |
| 177 | + | /// window shut, so this is the only way to reach the banner and the | |
| 178 | + | /// list/founder toggle on `/pricing`. | |
| 179 | + | #[allow(dead_code)] | |
| 180 | + | pub(crate) async fn with_founder_window_open() -> Self { | |
| 181 | + | Self::build(BuildOptions { | |
| 182 | + | founder_window_open: true, | |
| 183 | + | ..Default::default() | |
| 184 | + | }) | |
| 185 | + | .await | |
| 186 | + | } | |
| 187 | + | ||
| 172 | 188 | /// Harness with in-memory storage backend. | |
| 173 | 189 | #[allow(dead_code)] | |
| 174 | 190 | pub(crate) async fn with_storage() -> Self { | |
| @@ -416,7 +432,7 @@ | |||
| 416 | 432 | tier_annual_prices: std::collections::HashMap::new(), | |
| 417 | 433 | tier_founder_prices: std::collections::HashMap::new(), | |
| 418 | 434 | tier_founder_annual_prices: std::collections::HashMap::new(), | |
| 419 | - | founder_window_open: false, | |
| 435 | + | founder_window_open: opts.founder_window_open, | |
| 420 | 436 | }, | |
| 421 | 437 | integrations: IntegrationsConfig { | |
| 422 | 438 | mt_base_url: None, |
| @@ -368,3 +368,71 @@ | |||
| 368 | 368 | "paywall must not list the items themselves" | |
| 369 | 369 | ); | |
| 370 | 370 | } | |
| 371 | + | ||
| 372 | + | // ── /pricing founder disclosure ── | |
| 373 | + | // | |
| 374 | + | // The page is the fee calculator, not a price list: the tier radio's `value` is | |
| 375 | + | // what hx-include sends, so it is the price the server computes the visitor's | |
| 376 | + | // outcome on. While founder pricing is on offer that value has to be the | |
| 377 | + | // founder price, or a visitor who can buy at half price is shown their outcome | |
| 378 | + | // at a price they will not pay. | |
| 379 | + | ||
| 380 | + | /// With the window shut the page is what it was before any of this existed: | |
| 381 | + | /// list prices, no banner, no toggle. No dead control. | |
| 382 | + | #[tokio::test] | |
| 383 | + | async fn pricing_page_without_founder_window_shows_list_prices_only() { | |
| 384 | + | let mut h = TestHarness::new().await; | |
| 385 | + | ||
| 386 | + | let resp = h.client.get("/pricing").await; | |
| 387 | + | assert_eq!(resp.status, 200); | |
| 388 | + | assert!( | |
| 389 | + | !resp.text.contains("Founder pricing open"), | |
| 390 | + | "founder banner rendered with the window shut" | |
| 391 | + | ); | |
| 392 | + | assert!( | |
| 393 | + | !resp.text.contains(r#"name="price-mode""#), | |
| 394 | + | "price toggle rendered with the window shut" | |
| 395 | + | ); | |
| 396 | + | ||
| 397 | + | let prices = makenotwork::tier_prices::TierPrices::global(); | |
| 398 | + | assert!( | |
| 399 | + | resp.text | |
| 400 | + | .contains(&format!(r#"value="{}""#, prices.basic_std)), | |
| 401 | + | "the Basic radio should carry the list price" | |
| 402 | + | ); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | /// With the window open the offer is disclosed and the calculator opens on the | |
| 406 | + | /// founder price, with both prices in the DOM so the toggle is a local swap. | |
| 407 | + | #[tokio::test] | |
| 408 | + | async fn pricing_page_with_founder_window_computes_on_founder_prices() { | |
| 409 | + | let mut h = TestHarness::with_founder_window_open().await; | |
| 410 | + | ||
| 411 | + | let resp = h.client.get("/pricing").await; | |
| 412 | + | assert_eq!(resp.status, 200); | |
| 413 | + | assert!( | |
| 414 | + | resp.text.contains("Founder pricing open"), | |
| 415 | + | "the offer is live and undisclosed on the page that prices it" | |
| 416 | + | ); | |
| 417 | + | assert!( | |
| 418 | + | resp.text.contains(r#"name="price-mode""#), | |
| 419 | + | "no way to see the post-window number" | |
| 420 | + | ); | |
| 421 | + | ||
| 422 | + | let prices = makenotwork::tier_prices::TierPrices::global(); | |
| 423 | + | assert_ne!( | |
| 424 | + | prices.basic_founder, prices.basic_std, | |
| 425 | + | "fixture makes this test vacuous if the two prices are equal" | |
| 426 | + | ); | |
| 427 | + | assert!( | |
| 428 | + | resp.text | |
| 429 | + | .contains(&format!(r#"value="{}""#, prices.basic_founder)), | |
| 430 | + | "the Basic radio should open on the founder price" | |
| 431 | + | ); | |
| 432 | + | // Both prices present, so <mnw-price-mode> can flip without a round trip. | |
| 433 | + | assert!( | |
| 434 | + | resp.text | |
| 435 | + | .contains(&format!(r#"data-price-std="{}""#, prices.basic_std)), | |
| 436 | + | "list price missing from the DOM, the toggle would have to fetch it" | |
| 437 | + | ); | |
| 438 | + | } |