Skip to main content

max / makenotwork

18.0 KB · 458 lines History Blame Raw
1 //! The Fan+ membership page at `/fan-plus`, described.
2 //!
3 //! The fourth public document, and the first that branches on the viewer for
4 //! something other than the site header. It replaces
5 //! `templates/pages/fan_plus.html`, `FanPlusTemplate` and
6 //! `landing::fan_plus_page`.
7 //!
8 //! # Three readers, one address, which is what the optional user bought
9 //!
10 //! `/team`, `/use-cases` and `/policy` read the same to everybody and used
11 //! [`super::Audience::Anyone`] only so the header could greet a signed-in
12 //! reader. This page uses it for the page:
13 //!
14 //! ```text
15 //! a visitor the pitch, and both ways to get an account
16 //! a reader, unsubscribed the pitch, and the control that subscribes
17 //! a reader, subscribed their membership's state, and nothing to buy
18 //! ```
19 //!
20 //! The visitor branch is the one worth naming. Fan+ needs an account, so a
21 //! visitor is offered `/join` and `/login` rather than a control they cannot
22 //! use; the template's own comment says why ("without the second this page is a
23 //! dead end for exactly the visitor it is written for") and it is carried here
24 //! because it is a product decision rather than markup.
25 //!
26 //! # The database is read only for a reader who might have a subscription
27 //!
28 //! A visitor's request makes no query at all, which is the shipped behaviour
29 //! and worth keeping: this is a marketing page, and the busiest thing about it
30 //! is people who have not signed up.
31 //!
32 //! # One thing lost, and it is 12 sites rather than this one
33 //!
34 //! `data-loading-text="Redirecting to Stripe..."`. [`Action::awaiting`] says
35 //! that a wait is happening and the design system draws it, but nothing carries
36 //! the sentence. Measured before assuming it was this page's problem: 12 of the
37 //! server's 16 `data-loading-text` sites say "Redirecting to Stripe..." or
38 //! "Opening Stripe...", so the attribute is not a general facility for wait
39 //! wording -- it is one idea, "this control hands you off to the payment
40 //! provider", spelled twelve times.
41 //!
42 //! `Action::leaving` is the near miss and does not fit: it is `Method::Get`,
43 //! and every one of the twelve is a POST to our own route that answers with a
44 //! redirect. Filed against quasicoherent rather than worked around with a
45 //! hand-written attribute inside a described form.
46 //!
47 //! # The visitor's sentence is the page's own, and says so
48 //!
49 //! [`super::own_prose`] rather than `Node::rich`. The two links in it point at
50 //! `/join` and `/login`, our own pages, and an untrusted source would have them
51 //! carrying `nofollow` -- which is what shipped until quasi grew a trust axis
52 //! separate from its richness one (quasicoherent `24a3b1df`, quasi 0.94).
53
54 use makeover_layout as layout;
55 use quasi_declare::declare;
56 use quasi_router::screen::Figure;
57 use quasi_router::{Document, Request, Response, RouteError};
58 use quasi_webview::Webview;
59
60 use crate::db;
61
62 /// The address, registered whole. See [`super::public_document_mount`].
63 pub const PATH: &str = "/fan-plus";
64
65 /// Where the subscription is actually started. Not this screen's route: it is
66 /// the Stripe checkout handoff, which already exists and already carries the
67 /// CSRF posture it needs.
68 const SUBSCRIBE: &str = "/stripe/fan-plus";
69
70 /// The page's own region, and what the skip link points at.
71 pub const PAGE_REGION: &str = "fan-plus";
72
73 const MEASURE: layout::Measure = layout::Measure::Wide;
74
75 /// What the membership costs, in whole dollars per month.
76 const PRICE: &str = "$8";
77
78 /// The region a member's own state is drawn in.
79 const MEMBERSHIP: &str = "fan-plus-membership";
80
81 /// The region the pitch is drawn in.
82 const PITCH: &str = "fan-plus-pitch";
83
84 /// What this request knows about the reader's membership.
85 ///
86 /// `pub(crate)` so `quasi::residuals` can name it: the residual checks live
87 /// beside the roster rather than in each screen, and a screen that branches has
88 /// to be filled under every branch to be checked at all.
89 pub(crate) enum Standing {
90 /// Nobody is signed in. The page is a pitch plus a way to get an account.
91 Visitor,
92 /// Signed in, not a member.
93 Unsubscribed,
94 /// Signed in and paying, with the date the current period ends when Stripe
95 /// has told us one.
96 Member { period_end: Option<String> },
97 }
98
99 /// The three questions the page asks about the reader, spelled once.
100 ///
101 /// Predicates rather than a dispatch: `Member` carries a value, and the form
102 /// has no binding pattern to reach it through an arm.
103 impl Standing {
104 /// Signed in and paying.
105 const fn is_member(&self) -> bool {
106 matches!(self, Self::Member { .. })
107 }
108
109 /// Signed in, not paying: the one reader who can be sold to directly.
110 const fn is_unsubscribed(&self) -> bool {
111 matches!(self, Self::Unsubscribed)
112 }
113
114 /// Nobody is signed in.
115 const fn is_visitor(&self) -> bool {
116 matches!(self, Self::Visitor)
117 }
118
119 /// When the current billing period ends, or nothing.
120 ///
121 /// Empty rather than `None`, so the description asks one question and reads
122 /// one answer instead of matching an `Option` it cannot spell a pattern for.
123 fn period_end(&self) -> &str {
124 match self {
125 Self::Member {
126 period_end: Some(end),
127 } => end,
128 _ => "",
129 }
130 }
131 }
132
133 /// The page.
134 ///
135 /// Kept beside the residual mount rather than replaced by it, and it is what the
136 /// residual is checked against: `quasi::residuals` asserts that filling the
137 /// compiled one gives back exactly what building and rendering this gives, on
138 /// every branch. A screen with no second way to produce its markup has nothing
139 /// to check the first one with.
140 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
141 // Moved out of the request rather than borrowed: the signature is quasi's,
142 // so the request arrives owned.
143 let carried = request.carried;
144 let just_subscribed = carried
145 .get("subscribed")
146 .is_some_and(|value| value.trim() == "true");
147
148 let standing = standing(viewer)?;
149
150 Ok(page_screen(&standing, just_subscribed).into())
151 }
152
153 /// The two things this page reads, for the mount that serves it from a residual.
154 ///
155 /// One read, stating the document and deciding which branches are filled.
156 ///
157 /// A failure to read the membership refuses, the way it does on every other
158 /// screen. Answering `Visitor` instead would show a paying member the page that
159 /// asks them to subscribe, and a page that quietly tells a reader the opposite
160 /// of the truth about their own money is worse than one that does not load.
161 pub(crate) fn reading(
162 viewer: &super::Viewer,
163 carried: &super::Carried,
164 ) -> Result<(Standing, bool), RouteError> {
165 Ok((standing(viewer)?, carried.says("subscribed", "true")))
166 }
167
168 /// Read the reader's membership, if there is a reader.
169 fn standing(viewer: &super::Viewer) -> Result<Standing, RouteError> {
170 let Some(user) = viewer.user.as_ref() else {
171 return Ok(Standing::Visitor);
172 };
173
174 let subscription = viewer
175 .block_on(db::fan_plus::get_fan_plus_by_user(&viewer.app.db, user.id))
176 .map_err(|_| RouteError::internal("your membership could not be read"))?;
177
178 Ok(match subscription {
179 Some(sub) if sub.status == "active" => Standing::Member {
180 period_end: sub
181 .current_period_end
182 .map(|end| end.format("%B %-d, %Y").to_string()),
183 },
184 _ => Standing::Unsubscribed,
185 })
186 }
187
188 declare! {
189 /// The whole document: the title, the measure, the body.
190 ///
191 /// Three readers at one address, which is what the optional user bought:
192 /// a visitor gets the pitch and both ways to get an account, a signed-in
193 /// reader gets the pitch and the control that subscribes, and a member gets
194 /// their membership's state and nothing to buy.
195 pub(crate) shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen;
196
197 screen single "Fan+ - Makenotwork" {
198 measured MEASURE;
199 documented Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"]));
200 summarised "Support the platform and get $5 of credit back every month, plus forum badges, \
201 signatures and image embeds.";
202
203 include page_region(standing, just_subscribed);
204 }
205 }
206
207 declare! {
208 /// The page's one region, split out so it can be staged.
209 ///
210 /// **The first screen on the seam whose residual branches.** `/policy` and
211 /// `/team` fold to one literal and `/use-cases` is literals and holes; this
212 /// page asks four questions about the reader, so its residual carries the
213 /// markup of every answer and the filler walks the arms the request picks.
214 /// That is the point rather than a cost: the three readers see three
215 /// documents, and all three are compiled.
216 #[staged]
217 pub(crate) shape page_region(standing: &Standing, just_subscribed: bool) -> Slot;
218
219 region PAGE_REGION as Pane {
220 page "Fan+";
221 banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed;
222
223 include membership(standing) when standing.is_member();
224 include pitch() unless standing.is_member();
225
226 act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed();
227 for visitor in copy "content/fan-plus.toml" as visitor {
228 include super::own_prose(visitor.body) when standing.is_visitor();
229 }
230 }
231 }
232
233 declare! {
234 /// What a member is shown: the state of the thing they are paying for.
235 ///
236 /// `#[staged]` because [`page_region`] includes it and hands it the
237 /// standing, which is a value the request brings. A staged `include` whose
238 /// arguments are not all literal opens a scope and calls the callee's
239 /// filler, so the callee has to have one; without the flag the call does
240 /// not resolve and rustc names `membership_fill`. The other direction is
241 /// [`pitch`], which takes nothing and is `#[constant]` instead.
242 #[staged]
243 shape membership(standing: &Standing) -> Slot;
244
245 region MEMBERSHIP as Group {
246 text "Your Fan+ membership is active.";
247 text "Current period ends: {standing.period_end()}"
248 unless standing.period_end().is_empty();
249 text "You'll receive a $5 credit code each billing cycle via email.";
250 }
251 }
252
253 declare! {
254 /// What somebody who is not a member is shown, whether or not they have an
255 /// account. The two branches differ only in what they are offered
256 /// afterwards.
257 ///
258 /// `#[constant]`: it reads nothing the request brings. `PRICE` is a `const`
259 /// and a staged path is kept rather than made a hole, so the figure is
260 /// evaluated once while the residual is derived. The benefits are copy read
261 /// at macro time, so the loop is unrolled before any AST exists.
262 #[constant]
263 shape pitch() -> Slot;
264
265 region PITCH as Group {
266 text "Support the platform and get something back every month.";
267 section "What you get";
268 list {
269 for benefit in copy "content/fan-plus.toml" as benefits {
270 row benefit.name {
271 secondary benefit.detail;
272 }
273 }
274 }
275 stats [Figure::new(PRICE, "per month")];
276 text "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \
277 platform's development and operations, while getting real value back each month.";
278 }
279 }
280
281 /// The document this screen is drawn in.
282 #[must_use]
283 pub fn renderer(viewer: &super::Viewer) -> Webview {
284 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
285 "{}{}",
286 crate::shell::skip_link(PAGE_REGION),
287 crate::shell::site_header(viewer.user.as_ref()),
288 )))
289 }
290
291 #[cfg(test)]
292 mod tests {
293 use super::*;
294
295 fn html(standing: &Standing, just_subscribed: bool) -> String {
296 use quasi_axum::Serves as _;
297
298 Webview::new().screen(&page_screen(standing, just_subscribed))
299 }
300
301 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
302 #[test]
303 fn the_document_carries_the_classes_the_template_carried() {
304 let screen = page_screen(&Standing::Visitor, false);
305
306 assert_eq!(
307 screen.document.body_class.as_deref(),
308 Some("padded-page fan-plus-page")
309 );
310 let rendered = html(&Standing::Visitor, false);
311 assert!(
312 rendered.contains("class=\"padded-page fan-plus-page\""),
313 "{rendered}"
314 );
315 }
316
317 /// The visitor branch, which is the one the template's own comment exists
318 /// to protect: no subscribe control, and both ways to get an account.
319 #[test]
320 fn a_visitor_is_offered_an_account_rather_than_a_dead_end() {
321 let html = html(&Standing::Visitor, false);
322
323 // Asserted on each link's own copy rather than on the bare hrefs: the
324 // site header carries `/join` and `/login` on every page, so an href
325 // alone would pass whatever this block said.
326 //
327 // Each link's own copy, and neither of them nofollowed. The `nofollow`
328 // half is the seal on this being `own_prose`: an untrusted source is
329 // hardened by the renderer and both anchors would carry it.
330 //
331 // `rel="noopener noreferrer"` does survive, from ammonia's default, and
332 // is left alone: it suppresses the referrer and the opener handle, not
333 // the crawl, so it costs nothing an internal link needs.
334 assert!(html.contains(r#"href="/join""#), "{html}");
335 assert!(html.contains(">Create an account</a> to join"), "{html}");
336 assert!(html.contains(r#"href="/login""#), "{html}");
337 assert!(
338 html.contains(">log in</a> if you already have one"),
339 "{html}"
340 );
341 assert!(
342 !html.contains("nofollow"),
343 "the page nofollowed its own links: {html}"
344 );
345 assert!(
346 !html.contains(SUBSCRIBE),
347 "a visitor cannot subscribe, so the control must not be drawn: {html}"
348 );
349 }
350
351 /// A signed-in reader who is not a member gets the pitch and the control.
352 #[test]
353 fn a_reader_who_is_not_a_member_is_offered_the_subscription() {
354 let html = html(&Standing::Unsubscribed, false);
355
356 assert!(html.contains(SUBSCRIBE), "{html}");
357 assert!(html.contains("Join Fan+"), "{html}");
358 assert!(
359 !html.contains(r#"href="/join""#),
360 "somebody signed in does not need an account: {html}"
361 );
362 }
363
364 /// A member is shown their membership and nothing to buy.
365 #[test]
366 fn a_member_is_shown_their_period_and_offered_nothing() {
367 let html = html(
368 &Standing::Member {
369 period_end: Some("March 4, 2027".into()),
370 },
371 false,
372 );
373
374 assert!(html.contains("March 4, 2027"), "{html}");
375 assert!(html.contains("membership is active"), "{html}");
376 assert!(
377 !html.contains(SUBSCRIBE),
378 "a member must not be sold to again: {html}"
379 );
380 assert!(
381 !html.contains("What you get"),
382 "the pitch is for people who have not bought: {html}"
383 );
384 }
385
386 /// Stripe does not always give a period end, and the row is dropped rather
387 /// than rendered empty.
388 #[test]
389 fn a_member_with_no_known_period_end_is_told_the_rest_anyway() {
390 let html = html(&Standing::Member { period_end: None }, false);
391
392 assert!(html.contains("membership is active"), "{html}");
393 assert!(!html.contains("Current period ends"), "{html}");
394 }
395
396 /// `?subscribed=true` is what Stripe sends the reader back with.
397 ///
398 /// Matched on the half of the sentence with no apostrophe in it: the
399 /// description layer escapes one to `&#39;`, so the literal from the source
400 /// never appears in the markup.
401 #[test]
402 fn the_welcome_banner_shows_only_on_the_way_back_from_checkout() {
403 assert!(html(&Standing::Member { period_end: None }, true).contains("now a Fan+ member"));
404 assert!(!html(&Standing::Member { period_end: None }, false).contains("now a Fan+ member"));
405 }
406
407 /// The benefits are what the code grants, and the landing page's Fan+ card
408 /// says the same four. Audited 2026-08-05; this keeps the count honest.
409 ///
410 /// Reads `content/fan-plus.toml` the way the macro reads it, which is
411 /// `policy`'s rule: the file is asked what the page should say rather than
412 /// a second copy of it being held here. The count stays asserted, because
413 /// the audit is about the number as much as the names.
414 #[test]
415 fn the_four_benefits_are_all_stated() {
416 let html = html(&Standing::Unsubscribed, false);
417
418 let copy: toml::Table = include_str!("../../content/fan-plus.toml")
419 .parse()
420 .expect("the fan-plus copy is TOML");
421 let benefits = copy["benefits"].as_array().expect("a list of benefits");
422 assert_eq!(benefits.len(), 4);
423 for benefit in benefits {
424 let name = benefit["name"].as_str().expect("a name");
425 let escaped = crate::helpers::escape_html(name);
426 assert!(html.contains(&escaped), "{name} missing");
427 }
428 }
429
430 /// `736f45a5`: the wait on the Stripe handoff is said by the description.
431 /// The template spelled it `data-loading-text="Redirecting to Stripe..."`
432 /// on the subscribe button, so the branch that draws that control asserts
433 /// the word replacing it, and every branch asserts the four spellings are
434 /// absent.
435 #[test]
436 fn the_subscribe_control_spells_no_spinner() {
437 assert!(
438 html(&Standing::Unsubscribed, false).contains("data-awaiting="),
439 "{}",
440 html(&Standing::Unsubscribed, false)
441 );
442
443 for rendered in [
444 html(&Standing::Visitor, false),
445 html(&Standing::Unsubscribed, false),
446 html(&Standing::Member { period_end: None }, false),
447 html(&Standing::Unsubscribed, true),
448 ] {
449 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
450 assert!(
451 !rendered.contains(spelling),
452 "{spelling} survives in {rendered}"
453 );
454 }
455 }
456 }
457 }
458