Skip to main content

max / makenotwork

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