Skip to main content

max / makenotwork

36.8 KB · 941 lines History Blame Raw
1 //! The public fee calculator at `/pricing`, described.
2 //!
3 //! `1e35bc8a`. The first screen on `base.html` to own its whole document, and
4 //! the first described screen this server serves to a reader with no session.
5 //! Both firsts are consequences of the page rather than goals: `/pricing` is a
6 //! marketing page with no writes on it, so there is no session to resolve and
7 //! nothing for the Askama half to keep.
8 //!
9 //! It replaces `templates/pages/pricing.html`, `templates/partials/fee_calculator.html`,
10 //! `landing::pricing_page`, `landing::pricing_compare` and the `<mnw-price-mode>`
11 //! custom element with its island script. The arithmetic is untouched:
12 //! [`crate::fee_calculator`] still computes every number from
13 //! `docs/business/assumptions.toml`, and nothing about the fee model runs in
14 //! the browser now any more than it did before.
15 //!
16 //! # The recompute is the region's, not a dial's
17 //!
18 //! `Slot::consults` (quasicoherent `cb62a9dc`). The calculator region names the
19 //! route, the wait and the region the answer lands in; the values it sends are
20 //! the questions it contains, gathered by containment. That is the whole of the
21 //! shipped `hx-trigger` block -- five `input changed delay:300ms from:#id`
22 //! clauses and an `hx-include` listing the same five ids again -- said once,
23 //! with nothing naming a dial.
24 //!
25 //! It also decides what `<mnw-price-mode>` was. That element swapped the two
26 //! `data-price-*` values on four radios in the DOM and re-fired the recompute,
27 //! so the browser held a price the server also knew. Under a region consult it
28 //! is one more dial inside the region: a two-option question, sent with the
29 //! rest, and [`compare`] computes on the founder price or the list price. No
30 //! custom element, no DOM rewriting, and one place that knows what a tier
31 //! costs.
32 //!
33 //! # The tier radio sends a tier, not a price
34 //!
35 //! The shipped radio's `value` was the price in dollars, which was the only
36 //! thing that could work while a script rewrote it. With the mode as its own
37 //! dial the price is a function of both, so the value is the tier's own name
38 //! and [`Dials`] resolves the pair. An old shared link carrying `?tier=16`
39 //! still lands where it did: a `tier` that parses as a number is read as
40 //! dollars, which is what it meant.
41 //!
42 //! # What the results panel says now, and what it stopped saying
43 //!
44 //! The partial hand-drew a two-segment bar with a marker, a crossover tick, an
45 //! axis and a legend, and `fee_calculator::Scale` existed to compute the four
46 //! percentages it positioned them with. All of it said one thing: where the
47 //! reader's volume sits against the crossover. That is a proportion of a set,
48 //! which is [`Meter`] -- with the reader's sales as `done` and the crossover as
49 //! `total`, so passing it overflows the bar, which
50 //! [`layout::Meter::done`] names as the case worth drawing. `Scale` goes with
51 //! the partial.
52 //!
53 //! # Two deliberate parity differences
54 //!
55 //! - **The footer's two script-driven links are not here.** `base.html`'s
56 //! footer ends with "What's new" and "Shortcuts", both `<a href="#"
57 //! data-action=..>` bound by `actions-pages.js`. A described act names a
58 //! route or a host behaviour the description can state, and "run the function
59 //! registered under this string" is neither. Both are reachable from every
60 //! other page on the site; `9f2ac7d1` is the vocabulary question.
61 //! - **The dials carry `USD` rather than a leading `$`.** `Field::unit` is
62 //! drawn after the value in every renderer, and `10 $` is the reading to
63 //! avoid. makeover-layout 0.33.0 ruled a unit is a fact about the value and
64 //! not part of the label, so the symbol does not move into the question's
65 //! name; a leading unit is still unsaid, which is filed rather than worked
66 //! around here.
67
68 use std::time::Duration;
69
70 use makeover_layout as layout;
71 use quasi_router::{
72 Action, Cell, Cells, Choice, Column, Consult, Document, Field, Meter, Node, RegionKind,
73 Request, Response, RouteError, Row, Screen, Slot,
74 };
75 use quasi_webview::Webview;
76
77 use crate::Billing;
78 use crate::fee_calculator::{self, Inputs, Outcome, Verdict};
79 use crate::tier_prices::TierPrices;
80
81 /// The address the screen answers, and the one `landing::pricing_page` gives up.
82 pub const PATH: &str = "/pricing";
83
84 /// The route the calculator asks when a dial moves, as the reader's browser
85 /// spells it. The router inside the nest sees it with [`PATH`] taken off.
86 const COMPARE: &str = "/pricing/compare";
87
88 /// The region that holds every dial and the results panel.
89 ///
90 /// Its id is what the shipped markup called it, and nothing outside this module
91 /// reaches for it: the include list it used to need is gone.
92 const CALCULATOR: &str = "pricing-calculator";
93
94 /// The region holding the whole page, and what the skip link jumps to.
95 const PAGE: &str = "pricing-page";
96
97 /// How wide the page runs. `pricing.html` said it as `centered-page`; it is a
98 /// described property now, and the renderer turns it back into that class.
99 const MEASURE: layout::Measure = layout::Measure::Contained;
100
101 /// The region a recompute replaces.
102 ///
103 /// `pub` so the tests name the constant rather than transcribing it.
104 pub const RESULTS: &str = "results-panel";
105
106 /// How long a dial must stand still before the calculator is re-asked.
107 ///
108 /// The shipped `delay:300ms`, unchanged. It is the description's for
109 /// [`Consult::after`]'s reason: how expensive a question is to ask is the
110 /// route's own fact, and no renderer can know it.
111 const SETTLES: Duration = Duration::from_millis(300);
112
113 // The names every dial submits under. `item_price`, `sales`, `other_pct` and
114 // `other_per_sale` are what the shipped inputs were named and what a shared
115 // calculator URL carries, so they are kept exactly.
116 const ITEM_PRICE: &str = "item_price";
117 const SALES: &str = "sales";
118 const TIER: &str = "tier";
119 const PRICE_MODE: &str = "price_mode";
120 const OTHER_PCT: &str = "other_pct";
121 const OTHER_PER_SALE: &str = "other_per_sale";
122
123 /// Calculate on the half-price founder rate.
124 const FOUNDER: &str = "founder";
125 /// Calculate on the standing list rate.
126 const LIST: &str = "list";
127
128 /// The state one `/pricing` request is answered against.
129 ///
130 /// Not [`super::Viewer`]: that factory resolves a session and refuses without
131 /// one, which is right for every screen behind a login and wrong for a
132 /// marketing page. Nothing here is per-reader, so the adapter holds one of
133 /// these for the life of the process rather than building one per request.
134 pub struct Pricing {
135 /// Stripe's published rates, the tier table and the calculator's opening
136 /// positions, all derived from `assumptions.toml` at startup.
137 pub billing: Billing,
138 /// Whether the half-price founder window is open. Decides whether the mode
139 /// question is asked at all, and which rate the page opens on.
140 pub founder_window_open: bool,
141 /// Whether `/changelog` resolves, which is the one conditional link in the
142 /// site footer.
143 pub changelog_published: bool,
144 }
145
146 /// Where every dial sits for one request.
147 ///
148 /// The query string merged over the configured defaults, then clamped. This is
149 /// `landing::PricingCompareQuery::resolve` moved intact, less the two display
150 /// strings the template needed: a described field carries its own value.
151 struct Dials {
152 inputs: Inputs,
153 /// Which tier is picked, by name. The price it resolves to is on `inputs`.
154 tier: &'static str,
155 /// Which rate the tier prices are read at.
156 mode: &'static str,
157 }
158
159 impl Dials {
160 /// Read the dials out of what the control was offered under.
161 fn read(state: &Pricing, carried: &quasi_router::Params) -> Self {
162 let prices = &state.billing.tier_prices;
163 let number = |name: &str| {
164 carried
165 .get(name)
166 .map(str::trim)
167 .and_then(|v| v.parse::<f64>().ok())
168 };
169
170 // With the window shut there is one rate and no question about it, so a
171 // mode arriving in the query is ignored rather than honoured: a shared
172 // link should not be able to price a window that has closed.
173 let mode = match (state.founder_window_open, carried.get(PRICE_MODE)) {
174 (false, _) => LIST,
175 (true, Some(LIST)) => LIST,
176 (true, _) => FOUNDER,
177 };
178
179 // A `tier` that parses as a number is a link written before the tier
180 // radio sent a name, when the value was the price itself. Honoured as
181 // dollars so those links land where they did.
182 let (tier, tier_cost) = match carried.get(TIER) {
183 Some(raw) => match raw.trim().parse::<f64>() {
184 Ok(dollars) if dollars >= 0.0 => (Tier::BASIC.key, dollars),
185 _ => {
186 let tier = Tier::named(raw).unwrap_or(Tier::BASIC);
187 (tier.key, f64::from(tier.price(prices, mode)))
188 }
189 },
190 None => (Tier::BASIC.key, f64::from(Tier::BASIC.price(prices, mode))),
191 };
192
193 let mut inputs = state
194 .billing
195 .fee_calculator
196 .default_inputs(f64::from(prices.basic_std));
197 if let Some(v) = number(ITEM_PRICE) {
198 inputs.item_price = v;
199 }
200 if let Some(v) = number(SALES) {
201 inputs.sales_per_month = v;
202 }
203 // Typed as a whole percent and held as a fraction, which is the one
204 // conversion this page does. The dial multiplies back out when it
205 // renders, so the reader sees what they typed.
206 if let Some(v) = number(OTHER_PCT) {
207 inputs.other_pct = v / 100.0;
208 }
209 if let Some(v) = number(OTHER_PER_SALE) {
210 inputs.other_per_sale = v;
211 }
212 inputs.tier_cost = tier_cost;
213
214 Self {
215 inputs: state.billing.fee_calculator.sanitize(inputs),
216 tier,
217 mode,
218 }
219 }
220
221 /// What the calculator makes of these positions.
222 fn outcome(&self, state: &Pricing) -> Outcome {
223 state.billing.fee_calculator.compute(self.inputs)
224 }
225 }
226
227 /// One tier, as the radio needs it.
228 ///
229 /// The four are a table here rather than eight branches in a template: the
230 /// shipped markup spelled every price twice, once as the radio's value and once
231 /// as the card's display, and a `{% if founder_window_open %}` around each.
232 struct Tier {
233 /// What the radio sends, and what a shared link carries.
234 key: &'static str,
235 /// What the card reads.
236 label: &'static str,
237 /// What the tier is for, in the reader's terms. The envelope comes off
238 /// [`TierPrices`] and is spliced in.
239 fits: &'static str,
240 }
241
242 impl Tier {
243 const BASIC: Self = Self {
244 key: "basic",
245 label: "Basic",
246 fits: "Fits text, blogs, newsletters.",
247 };
248 const SMALL_FILES: Self = Self {
249 key: "small_files",
250 label: "Small Files",
251 fits: "Fits audio, plugins, binaries.",
252 };
253 const BIG_FILES: Self = Self {
254 key: "big_files",
255 label: "Big Files",
256 fits: "Fits video, games, large software.",
257 };
258 const EVERYTHING: Self = Self {
259 key: "everything",
260 label: "Everything",
261 fits: "Big Files envelope plus first access to high-cost features as they ship.",
262 };
263
264 /// The four, in the order the cards are read.
265 const ALL: [Self; 4] = [
266 Self::BASIC,
267 Self::SMALL_FILES,
268 Self::BIG_FILES,
269 Self::EVERYTHING,
270 ];
271
272 /// The tier this key names, if it names one.
273 fn named(key: &str) -> Option<Self> {
274 Self::ALL.into_iter().find(|tier| tier.key == key)
275 }
276
277 /// What it costs a month at this rate.
278 fn price(&self, prices: &TierPrices, mode: &str) -> i32 {
279 let founder = mode == FOUNDER;
280 match self.key {
281 "small_files" => {
282 if founder {
283 prices.small_files_founder
284 } else {
285 prices.small_files_std
286 }
287 }
288 "big_files" => {
289 if founder {
290 prices.big_files_founder
291 } else {
292 prices.big_files_std
293 }
294 }
295 "everything" => {
296 if founder {
297 prices.everything_founder
298 } else {
299 prices.everything_std
300 }
301 }
302 // Basic, and the arm a tier added upstream lands in: the cheapest
303 // envelope is a wrong price rather than a panic on a public page.
304 _ => {
305 if founder {
306 prices.basic_founder
307 } else {
308 prices.basic_std
309 }
310 }
311 }
312 }
313
314 /// The second line under the tier's name: what it costs, what it holds and
315 /// what that suits.
316 fn detail(&self, prices: &TierPrices, mode: &str) -> String {
317 let price = self.price(prices, mode);
318 match self.key {
319 "small_files" => format!(
320 "${price}/mo. {}/file, {} total. {}",
321 prices.small_files_per_file, prices.small_files_total, self.fits
322 ),
323 "big_files" => format!(
324 "${price}/mo. {}/file, {} total. {}",
325 prices.big_files_per_file, prices.big_files_total, self.fits
326 ),
327 // Everything's envelope is Big Files', which its own sentence says,
328 // so it names no caps of its own.
329 "everything" => format!("${price}/mo. {}", self.fits),
330 _ => format!(
331 "${price}/mo. {}/file, {} total. {}",
332 prices.basic_per_file, prices.basic_total, self.fits
333 ),
334 }
335 }
336 }
337
338 /// The whole page.
339 pub fn screen(state: &Pricing, request: Request) -> Result<Response, RouteError> {
340 // The bag is moved out of the request rather than borrowed from it: the
341 // handler signature is quasi's, so the request arrives owned and nothing
342 // else here reads it.
343 let carried = request.carried;
344 let dials = Dials::read(state, &carried);
345 Ok(page(state, &dials).into())
346 }
347
348 /// A recompute: the results panel and nothing else.
349 ///
350 /// Pure arithmetic over the dials, no session and no state change, which is why
351 /// it is a GET and why the screen it belongs to needs no token.
352 pub fn compare(state: &Pricing, request: Request) -> Result<Response, RouteError> {
353 let carried = request.carried;
354 let dials = Dials::read(state, &carried);
355 Ok(Response::fragment(
356 RESULTS,
357 Node::Region(results(&dials.outcome(state))),
358 ))
359 }
360
361 /// The described document, top to bottom.
362 fn page(state: &Pricing, dials: &Dials) -> Screen {
363 Screen::list_detail("Pricing Calculator - Makenotwork", false)
364 .measured(MEASURE)
365 // What `pricing.html` said as `class="centered-page"`, read off the
366 // measure declared on the line above rather than off a route table.
367 //
368 // On the screen and not on the shell (quasicoherent `ee1882e0`). The
369 // shell is built once and `Arc`'d at adapter construction, so a class
370 // set there is a constant for every screen that adapter ever serves --
371 // which is right for one page and silently wrong for the second, and
372 // the second is what the conversion is producing. The empty slice is
373 // `pricing.html` carrying nothing beside the measure, which is why this
374 // screen needed no mapping at all.
375 .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
376 // The whole of what `base.html` put in `<meta name="description">`,
377 // including the fee sentence, because this one string is now all three
378 // tags: the social pair and the plain one. It carried only the first
379 // sentence while the plain tag was appended separately.
380 .summarised(
381 "Work out what you keep on every sale here, against whatever the \
382 platform you sell on now deducts. 0% platform fee, only the \
383 payment processor's ~3%.",
384 )
385 .with(
386 Slot::new(PAGE, RegionKind::Pane)
387 .with(Node::page("Pricing Calculator"))
388 .with(Node::text("See what you keep on every sale."))
389 .with(Node::Region(calculator(state, dials)))
390 .with(Node::act("Join the Alpha", Action::get("/join")))
391 .with(Node::List {
392 rows: vec![
393 Row::new("Home").activate(Action::get("/")),
394 Row::new("Browse as guest").activate(Action::get("/discover")),
395 ],
396 more: None,
397 })
398 .with(Node::Region(footer(state))),
399 )
400 }
401
402 /// Every dial, and the panel they recompute.
403 fn calculator(state: &Pricing, dials: &Dials) -> Slot {
404 let prices = &state.billing.tier_prices;
405 let inputs = dials.inputs;
406
407 let mut slot = Slot::group(CALCULATOR)
408 .consulting(Consult::new(Action::get(COMPARE).replacing(RESULTS)).after(SETTLES))
409 .with(Node::section("What you sell"))
410 .with(Node::field(dial(
411 ITEM_PRICE,
412 "Price per item",
413 inputs.item_price,
414 fee_calculator::MAX_ITEM_PRICE,
415 "1",
416 "USD",
417 )))
418 .with(Node::field(dial(
419 SALES,
420 "Sales per month",
421 inputs.sales_per_month,
422 fee_calculator::MAX_SALES,
423 "1",
424 "/mo",
425 )));
426
427 // Same wording as the landing tagline, because it is the same offer.
428 if state.founder_window_open {
429 slot = slot.with(Node::banner(
430 layout::Tone::Success,
431 "Founder pricing open. Half off creator tiers, locked for life. The \
432 calculator is using founder prices.",
433 ));
434 }
435
436 slot = slot
437 .with(Node::section("Your content tier"))
438 .with(Node::text(
439 "Every tier is the complete platform: profile, project pages, forum, \
440 discovery, memberships, analytics, full data export. The tier picks \
441 the file-size envelope, not the feature set.",
442 ));
443
444 // Only while there are two rates to choose between. With the window shut
445 // the page is what it was before either the toggle or this question
446 // existed.
447 if state.founder_window_open {
448 slot = slot.with(Node::field(
449 Field::radio(
450 PRICE_MODE,
451 "Calculate with",
452 vec![
453 Choice::new(FOUNDER, "Founder price"),
454 Choice::new(LIST, "List price"),
455 ],
456 )
457 .value(dials.mode),
458 ));
459 }
460
461 slot = slot
462 .with(Node::field(
463 Field::radio(
464 TIER,
465 "Content tier",
466 Tier::ALL
467 .iter()
468 .map(|tier| {
469 Choice::new(tier.key, tier.label).detailing(tier.detail(prices, dials.mode))
470 })
471 .collect(),
472 )
473 .value(dials.tier),
474 ))
475 .with(Node::section("Wherever else you sell"))
476 .with(Node::text(
477 "Fill in what the other platform takes. Use its total deduction, its \
478 own cut plus any payment processing it adds, which is the figure you \
479 can read off a payout. We hold no rates for anyone but ourselves, so \
480 nothing here can go stale or be picked to flatter us.",
481 ))
482 .with(Node::field(dial(
483 OTHER_PCT,
484 "Their cut",
485 inputs.other_pct * 100.0,
486 fee_calculator::MAX_OTHER_PCT * 100.0,
487 "0.1",
488 "%",
489 )))
490 .with(Node::field(dial(
491 OTHER_PER_SALE,
492 "Their fee per sale",
493 inputs.other_per_sale,
494 fee_calculator::MAX_OTHER_PER_SALE,
495 "0.05",
496 "USD",
497 )))
498 .with(Node::Region(results(&dials.outcome(state))));
499
500 slot
501 }
502
503 /// A dial: a bounded number holding what it holds, in the unit it is measured
504 /// in.
505 ///
506 /// `min` and `max` are set on the struct rather than through a builder because
507 /// `Field::range` is the slider's constructor and these are typed boxes: the
508 /// bounds are a rule the answer is checked against, not the control itself.
509 /// They are `fee_calculator`'s own constants, so the box refuses what
510 /// `FeeCalculator::sanitize` would clamp instead of silently disagreeing with
511 /// it.
512 fn dial(
513 name: &'static str,
514 label: &'static str,
515 value: f64,
516 max: f64,
517 step: &'static str,
518 unit: &'static str,
519 ) -> Field {
520 let mut field = Field::new(layout::FieldKind::Number, name, label)
521 .value(fmt_dial(value))
522 .step(step)
523 .unit(unit);
524 field.min = Some("0".to_string());
525 field.max = Some(fmt_dial(max));
526 field
527 }
528
529 /// The panel the recompute replaces.
530 ///
531 /// Returns the [`Slot`] rather than a [`Node`] so the screen can nest it and
532 /// [`compare`] can answer with it, which is the one thing both paths have to
533 /// agree about.
534 fn results(outcome: &Outcome) -> Slot {
535 let mut slot = Slot::new(RESULTS, RegionKind::Pane)
536 .with(Node::banner(tone(outcome.verdict), &outcome.headline))
537 .with(Node::Table {
538 columns: vec![
539 Column::new("Monthly")
540 .width(layout::Width::Fill)
541 .priority(layout::Priority::Essential),
542 Column::new("Here")
543 .width(layout::Width::Content)
544 .priority(layout::Priority::Essential),
545 Column::new("The other platform")
546 .width(layout::Width::Content)
547 .priority(layout::Priority::Essential),
548 ],
549 rows: vec![
550 Cells::new([
551 Cell::new("You sell"),
552 Cell::new(outcome.gross.clone()),
553 Cell::new(outcome.gross.clone()),
554 ]),
555 Cells::new([
556 Cell::new("You keep"),
557 Cell::new(outcome.mnw_keep.clone()),
558 Cell::new(outcome.other_keep.clone()),
559 ]),
560 Cells::new([
561 Cell::new("Total fees"),
562 Cell::new(outcome.mnw_rate.clone()),
563 Cell::new(outcome.other_rate.clone()),
564 ]),
565 ],
566 more: None,
567 });
568
569 // Where the reader's volume sits against the crossover, which is the whole
570 // of what the hand-drawn two-segment bar said. Absent when there is no
571 // crossover, because there is then no set to be a proportion of, and the
572 // note below says why in words.
573 if let Some(crossover) = outcome.crossover_sales {
574 slot = slot
575 .with(Node::section("Where each one wins"))
576 .with(Node::Meter(
577 Meter::new(
578 outcome.sales_per_month.round().max(0.0) as u32,
579 crossover.ceil().max(1.0) as u32,
580 )
581 .tone(tone(outcome.verdict))
582 .label("sales a month to the crossover"),
583 ));
584 }
585
586 if let Some(note) = &outcome.crossover_note {
587 slot = slot.with(Node::text(note));
588 }
589
590 slot.with(Node::text(
591 "Our side of this uses the payment processor's published US card rates. \
592 Yours is whatever you type in, so check it against a real payout rather \
593 than a pricing page: some platforms quote their cut before processing \
594 and some after.",
595 ))
596 .with(Node::text(
597 "Selling small-ticket items? Payment processors charge a fixed fee per \
598 sale (~$0.30) that hits harder on $1-5 items. That is an industry-wide \
599 constraint and it applies wherever you sell. Bundling items into \
600 collections lets fans buy in groups at a single transaction cost \
601 instead of paying per-item processing.",
602 ))
603 }
604
605 /// What a verdict means, as every renderer already draws it.
606 ///
607 /// The template branched on `Verdict::css_class`, three class names this
608 /// server's own stylesheet defined. A tone says the same thing in a word every
609 /// host has.
610 const fn tone(verdict: Verdict) -> layout::Tone {
611 match verdict {
612 Verdict::MnwAhead => layout::Tone::Success,
613 Verdict::Even => layout::Tone::Neutral,
614 Verdict::OtherAheadForNow | Verdict::OtherAhead => layout::Tone::Warning,
615 }
616 }
617
618 /// The site footer, described.
619 ///
620 /// Here rather than in `base.html` because this screen owns its document. It
621 /// stays in this module while it is the only such screen; the second one moves
622 /// it out, and moving it is the cheaper half of that conversion.
623 fn footer(state: &Pricing) -> Slot {
624 let mut rows = vec![
625 Row::new("Pricing").activate(Action::get("/pricing")),
626 Row::new("Creators").activate(Action::get("/creators")),
627 Row::new("Docs").activate(Action::get("/docs")),
628 Row::new("Legal").activate(Action::get("/policy")),
629 Row::new("Credits").activate(Action::get("/docs/credits")),
630 ];
631 // Linked only while a published changelog project exists; the route 404s
632 // otherwise. See `crate::changelog`.
633 if state.changelog_published {
634 rows.push(Row::new("Changelog").activate(Action::get("/changelog")));
635 }
636 rows.push(Row::new("Contact").activate(Action::external("mailto:info@makenot.work")));
637 rows.push(Row::new("Status").activate(Action::get("/health")));
638
639 Slot::new("site-footer", RegionKind::Pane)
640 .with(Node::List { rows, more: None })
641 .with(Node::text("(c) 2026 Make Creative, LLC"))
642 }
643
644 /// Render a dial's value: no trailing zeros on a whole number, at most two
645 /// decimals otherwise. `12.6`, `0.30` and `25` all read as typed.
646 ///
647 /// `landing::fmt_dial`, moved with the two dials that needed it.
648 fn fmt_dial(v: f64) -> String {
649 let s = format!("{v:.2}");
650 s.trim_end_matches('0').trim_end_matches('.').to_string()
651 }
652
653 /// The document this screen is drawn in.
654 ///
655 /// Everything from `<!doctype>` to `</html>`, which is the part `base.html`
656 /// owned for every other page on this site. Three things it has to carry that
657 /// a region-sized screen never did:
658 ///
659 /// 1. **The body class.** `pricing.html` wrote
660 /// `class="{{ shell::measure(Measure::Contained) }}"`, and the measure is a
661 /// described property now ([`Screen::measured`]), so the class is read off
662 /// the screen rather than off a route table. That is what `2790e5c4` asks
663 /// for, answered from the description instead of from a mapping this server
664 /// would have to keep in step with 74 templates.
665 /// 2. **The head.** [`crate::shell`] already owns it for the Askama pages, and
666 /// the same [`quasi_webview::Shell`] builds it here, so the two documents
667 /// cannot drift.
668 /// 3. **The tail.** `base.html` ends with a toast container and seven classic
669 /// script shims that the `data-action` dispatcher resolves through. They are
670 /// markup no description will name -- a script tag, and a container another
671 /// script writes into -- which is what `Shell::with_body_last` is for.
672 #[must_use]
673 pub fn renderer() -> Webview {
674 let shell = crate::shell::described()
675 .with_body_first(crate::shell::skip_link(PAGE))
676 .with_body_last(crate::shell::body_last())
677 // What this site offers from everywhere, which today is one key
678 // (`e0c0d991`). On the shell rather than on a screen because that is
679 // the whole claim chrome makes: an affordance reachable from every
680 // screen is not a fact about any one of them, and every screen
681 // converted after this inherits it with no further work.
682 //
683 // It is also what gives `Outcome::Over` somewhere to land -- a renderer
684 // emits the overlay container for an app that declares chrome, and an
685 // app that declares none gets a swap that does nothing.
686 .with_chrome(crate::quasi::shortcuts::chrome());
687 // The `Shell::head` append that stood here is gone as of quasi 0.80
688 // (quasicoherent `a0e16839`). It wrote the plain `<meta name="description">`
689 // by hand because `Screen::summarised` reached `og:description` and
690 // `twitter:description` and stopped; it reaches all three now, off the one
691 // string the screen already declares. An escape hatch spending itself on
692 // something a described property carries is the escape hatch going unused.
693 //
694 Webview::new().with_shell(shell)
695 }
696
697 #[cfg(test)]
698 mod tests {
699 use super::*;
700
701 /// A calculator built from the canonical assumptions, at either rate.
702 ///
703 /// The real table and the real Stripe fees, because the thing under test is
704 /// which of them a dial reaches rather than what they are: a fixture with
705 /// invented prices would pass while the tier lookup read the wrong column.
706 fn state(founder_window_open: bool) -> Pricing {
707 crate::tier_prices::TierPrices::install_test_default();
708 Pricing {
709 billing: Billing {
710 payments: None,
711 payment_caps: crate::payments::PaymentCapabilities::default(),
712 tier_prices: crate::tier_prices::TierPrices::global().clone(),
713 runway_config: crate::tier_prices::RunwayConfig {
714 quarters: 0,
715 last_updated_iso: String::new(),
716 },
717 fee_calculator: crate::fee_calculator::FeeCalculator::load(
718 "docs/business/assumptions.toml",
719 ),
720 },
721 founder_window_open,
722 changelog_published: false,
723 }
724 }
725
726 fn carrying(pairs: &[(&str, &str)]) -> quasi_router::Params {
727 pairs.iter().copied().collect()
728 }
729
730 /// A dial position, spelled with a tolerance so `float_cmp` stays happy.
731 /// The same helper `fee_calculator::tests` uses, for its reason.
732 fn approx(got: f64, want: f64, what: &str) {
733 assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}");
734 }
735
736 /// The whole of the shipped `hx-trigger`/`hx-include` block, said once.
737 ///
738 /// Five `input changed delay:300ms from:#id` clauses and five ids listed
739 /// again to be sent. Here the region names the route, the wait and the
740 /// landing place, and names no dial at all.
741 #[test]
742 fn the_region_asks_and_nothing_names_a_dial() {
743 let state = state(true);
744 let screen = page(&state, &Dials::read(&state, &carrying(&[])));
745
746 let asking = screen.consulting();
747 assert_eq!(asking.len(), 1, "one panel recomputes, not several");
748 let consult = &asking[0].consults[0];
749 assert_eq!(consult.action.route(), Some(COMPARE));
750 assert_eq!(consult.after, SETTLES);
751 assert!(
752 consult.sends.is_empty(),
753 "a dial inside the region rides along by containment, so nothing \
754 should be named: {:?}",
755 consult.sends
756 );
757 }
758
759 /// Every dial the recompute needs is inside the region that asks, which is
760 /// what containment means here. Six with the window open, because the mode
761 /// is a dial like any other.
762 #[test]
763 fn the_region_contains_every_dial_the_route_reads() {
764 let state = state(true);
765 let screen = page(&state, &Dials::read(&state, &carrying(&[])));
766 let names: Vec<&str> = screen.consulting()[0]
767 .questions()
768 .iter()
769 .map(|field| field.name.as_str())
770 .collect();
771
772 assert_eq!(
773 names,
774 [
775 ITEM_PRICE,
776 SALES,
777 PRICE_MODE,
778 TIER,
779 OTHER_PCT,
780 OTHER_PER_SALE
781 ]
782 );
783 }
784
785 /// With the window shut there is one rate, so there is no question to ask
786 /// about it and no control that does nothing.
787 #[test]
788 fn the_mode_is_not_asked_once_the_window_shuts() {
789 let state = state(false);
790 let screen = page(&state, &Dials::read(&state, &carrying(&[])));
791 let names: Vec<&str> = screen.consulting()[0]
792 .questions()
793 .iter()
794 .map(|field| field.name.as_str())
795 .collect();
796
797 assert!(!names.contains(&PRICE_MODE), "{names:?}");
798 }
799
800 /// The mode reaches the arithmetic. This is what `<mnw-price-mode>` did in
801 /// the DOM, and the whole reason it could be deleted.
802 #[test]
803 fn the_mode_picks_which_rate_the_tier_costs() {
804 let state = state(true);
805 let prices = &state.billing.tier_prices;
806 assert_ne!(
807 prices.basic_founder, prices.basic_std,
808 "the assumptions make this vacuous if the two rates are equal"
809 );
810
811 let founder = Dials::read(&state, &carrying(&[(TIER, "basic")]));
812 let list = Dials::read(&state, &carrying(&[(TIER, "basic"), (PRICE_MODE, LIST)]));
813
814 approx(
815 founder.inputs.tier_cost,
816 f64::from(prices.basic_founder),
817 "founder rate",
818 );
819 approx(
820 list.inputs.tier_cost,
821 f64::from(prices.basic_std),
822 "list rate",
823 );
824 }
825
826 /// A shut window prices at list whatever a shared link says, so a link
827 /// cannot resurrect an offer that has ended.
828 #[test]
829 fn a_link_cannot_price_a_window_that_has_closed() {
830 let state = state(false);
831 let dials = Dials::read(&state, &carrying(&[(TIER, "basic"), (PRICE_MODE, FOUNDER)]));
832
833 assert_eq!(dials.mode, LIST);
834 approx(
835 dials.inputs.tier_cost,
836 f64::from(state.billing.tier_prices.basic_std),
837 "a shut window prices at list",
838 );
839 }
840
841 /// The radio's value used to be the price itself, so a link written then
842 /// carries dollars where a tier name goes now.
843 #[test]
844 fn an_older_link_carrying_a_price_is_read_as_dollars() {
845 let state = state(true);
846 let dials = Dials::read(&state, &carrying(&[(TIER, "24"), (SALES, "100")]));
847
848 approx(dials.inputs.tier_cost, 24.0, "the price the link carried");
849 approx(dials.inputs.sales_per_month, 100.0, "sales");
850 }
851
852 /// The cut is typed as a whole percent and held as a fraction, which is the
853 /// one conversion this page does.
854 #[test]
855 fn the_cut_is_typed_whole_and_held_as_a_fraction() {
856 let state = state(false);
857 let dials = Dials::read(&state, &carrying(&[(OTHER_PCT, "12.6")]));
858
859 approx(dials.inputs.other_pct, 0.126, "12.6% as a fraction");
860 }
861
862 /// The class `pricing.html` carried, still on `<body>` and now the
863 /// screen's rather than the adapter's.
864 ///
865 /// quasicoherent `ee1882e0`. Asserted on the screen rather than on the
866 /// emitted markup because that is where the change is: the renderer folding
867 /// a document into a `<body>` tag is quasi-webview's own test, and this
868 /// server does not link `quasi-http` to call `Serves::screen` here.
869 #[test]
870 fn the_document_carries_the_class_the_template_carried() {
871 let screen = page(&state(false), &Dials::read(&state(false), &carrying(&[])));
872
873 assert_eq!(
874 screen.document.body_class.as_deref(),
875 Some(crate::shell::body_class(MEASURE, &[]).as_str())
876 );
877 assert_eq!(screen.document.body_class.as_deref(), Some("centered-page"));
878
879 use quasi_axum::Serves as _;
880
881 let rendered = Webview::new().screen(&page(
882 &state(false),
883 &Dials::read(&state(false), &carrying(&[])),
884 ));
885 assert!(rendered.contains("class=\"centered-page\""), "{rendered}");
886 }
887
888 /// `736f45a5`. The calculator answers in the page rather than over the
889 /// wire, so there is no wait to draw and no spelling to carry.
890 #[test]
891 fn the_page_spells_no_spinner() {
892 use quasi_axum::Serves as _;
893
894 let rendered = Webview::new().screen(&page(
895 &state(false),
896 &Dials::read(&state(false), &carrying(&[])),
897 ));
898
899 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
900 assert!(
901 !rendered.contains(spelling),
902 "{spelling} survives in {rendered}"
903 );
904 }
905 }
906
907 /// Where the reader sits against the crossover, drawn only when there is a
908 /// crossover to sit against.
909 #[test]
910 fn the_panel_meters_the_crossover_only_when_there_is_one() {
911 let state = state(false);
912 let calculator = &state.billing.fee_calculator;
913
914 let ahead = calculator.compute(Inputs {
915 item_price: 25.0,
916 sales_per_month: 40.0,
917 tier_cost: 16.0,
918 other_pct: 0.126,
919 other_per_sale: 0.30,
920 });
921 assert!(has_meter(&results(&ahead)), "a crossover with no meter");
922
923 // Their cut is below our processing, so no volume closes the gap and
924 // there is no set for a proportion to be of.
925 let never = calculator.compute(Inputs {
926 item_price: 5.0,
927 sales_per_month: 500.0,
928 tier_cost: 16.0,
929 other_pct: 0.01,
930 other_per_sale: 0.0,
931 });
932 assert!(!has_meter(&results(&never)), "metered against nothing");
933 }
934
935 fn has_meter(slot: &Slot) -> bool {
936 slot.body
937 .iter()
938 .any(|placed| matches!(placed.node, Node::Meter(_)))
939 }
940 }
941