Skip to main content

max / makenotwork

14.8 KB · 386 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_router::screen::{Figure, Row};
56 use quasi_router::{
57 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
58 };
59 use quasi_webview::Webview;
60
61 use crate::db;
62
63 /// The address, registered whole. See [`super::public_document_mount`].
64 pub const PATH: &str = "/fan-plus";
65
66 /// Where the subscription is actually started. Not this screen's route: it is
67 /// the Stripe checkout handoff, which already exists and already carries the
68 /// CSRF posture it needs.
69 const SUBSCRIBE: &str = "/stripe/fan-plus";
70
71 /// The page's own region, and what the skip link points at.
72 pub const PAGE_REGION: &str = "fan-plus";
73
74 const MEASURE: layout::Measure = layout::Measure::Wide;
75
76 /// What the membership costs, in whole dollars per month.
77 const PRICE: &str = "$8";
78
79 /// What a member gets.
80 ///
81 /// **Keep this list and the Fan+ card on the landing page identical, and keep
82 /// both to what the code grants.** Audited 2026-08-05, carried over from the
83 /// template's comment because it is a rule rather than a note: a benefit listed
84 /// here and not granted by the code is a promise nobody implemented.
85 const BENEFITS: &[(&str, &str)] = &[
86 (
87 "$5 monthly credit",
88 "A promo code delivered by email each billing cycle, usable toward any purchase on the platform",
89 ),
90 ("+ badge", "Displayed next to your name on your forum posts"),
91 (
92 "Forum signatures",
93 "A signature block rendered under everything you post",
94 ),
95 ("Image embeds", "Post images in forum threads"),
96 ];
97
98 /// What this request knows about the reader's membership.
99 enum Standing {
100 /// Nobody is signed in. The page is a pitch plus a way to get an account.
101 Visitor,
102 /// Signed in, not a member.
103 Unsubscribed,
104 /// Signed in and paying, with the date the current period ends when Stripe
105 /// has told us one.
106 Member { period_end: Option<String> },
107 }
108
109 /// The page.
110 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
111 // Moved out of the request rather than borrowed: the signature is quasi's,
112 // so the request arrives owned.
113 let carried = request.carried;
114 let just_subscribed = carried
115 .get("subscribed")
116 .is_some_and(|value| value.trim() == "true");
117
118 let standing = standing(viewer)?;
119
120 Ok(page_screen(&standing, just_subscribed).into())
121 }
122
123 /// Read the reader's membership, if there is a reader.
124 fn standing(viewer: &super::Viewer) -> Result<Standing, RouteError> {
125 let Some(user) = viewer.user.as_ref() else {
126 return Ok(Standing::Visitor);
127 };
128
129 let subscription = viewer
130 .block_on(db::fan_plus::get_fan_plus_by_user(&viewer.app.db, user.id))
131 .map_err(|_| RouteError::internal("your membership could not be read"))?;
132
133 Ok(match subscription {
134 Some(sub) if sub.status == "active" => Standing::Member {
135 period_end: sub
136 .current_period_end
137 .map(|end| end.format("%B %-d, %Y").to_string()),
138 },
139 _ => Standing::Unsubscribed,
140 })
141 }
142
143 /// The whole document: the title, the measure, the body.
144 fn page_screen(standing: &Standing, just_subscribed: bool) -> Described {
145 let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Fan+"));
146
147 if just_subscribed {
148 page = page.with(Node::banner(
149 layout::Tone::Success,
150 "You're now a Fan+ member. Welcome.",
151 ));
152 }
153
154 page = match standing {
155 Standing::Member { period_end } => membership(page, period_end.as_deref()),
156 Standing::Unsubscribed => {
157 pitch(page).with(Node::act("Join Fan+", Action::post(SUBSCRIBE).awaiting()))
158 }
159 Standing::Visitor => {
160 // Fan+ needs an account, so a visitor gets both paths: the one for
161 // people who already have one and the one for people who do not.
162 // Without the second this page is a dead end for exactly the
163 // visitor it is written for (`loose-wire g1-23`, and
164 // `fan_plus_page_renders_for_anonymous` is the seal).
165 //
166 // One sentence with two inline links, not two buttons. The sentence
167 // says which link is for whom and a pair of buttons does not, and
168 // that distinction is the whole point of the finding above. It is
169 // prose, so it is `Node::rich`, on `/policy`'s rule.
170 pitch(page).with(super::own_prose(
171 "[Create an account](/join) to join, or [log in](/login) if you already have one.",
172 ))
173 }
174 };
175
176 Described::single("Fan+ - Makenotwork")
177 .measured(MEASURE)
178 .documented(
179 Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])),
180 )
181 .summarised(
182 "Support the platform and get $5 of credit back every month, plus forum badges, \
183 signatures and image embeds.",
184 )
185 .with(page)
186 }
187
188 /// What a member is shown: the state of the thing they are paying for.
189 fn membership(page: Slot, period_end: Option<&str>) -> Slot {
190 let mut page = page.with(Node::text("Your Fan+ membership is active."));
191
192 if let Some(end) = period_end {
193 page = page.with(Node::text(format!("Current period ends: {end}")));
194 }
195
196 page.with(Node::text(
197 "You'll receive a $5 credit code each billing cycle via email.",
198 ))
199 }
200
201 /// What somebody who is not a member is shown, whether or not they have an
202 /// account. The two branches differ only in what they are offered afterwards.
203 fn pitch(page: Slot) -> Slot {
204 page.with(Node::text(
205 "Support the platform and get something back every month.",
206 ))
207 .with(Node::section("What you get"))
208 .with(Node::list(
209 BENEFITS
210 .iter()
211 .map(|(name, detail)| Row::new(*name).secondary(*detail)),
212 ))
213 .with(Node::stats([Figure::new(PRICE, "per month")]))
214 .with(Node::text(
215 "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \
216 platform's development and operations, while getting real value back each month.",
217 ))
218 }
219
220 /// The document this screen is drawn in.
221 #[must_use]
222 pub fn renderer(viewer: &super::Viewer) -> Webview {
223 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
224 "{}{}",
225 crate::shell::skip_link(PAGE_REGION),
226 crate::shell::site_header(viewer.user.as_ref()),
227 )))
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233
234 fn html(standing: &Standing, just_subscribed: bool) -> String {
235 use quasi_axum::Serves as _;
236
237 Webview::new().screen(&page_screen(standing, just_subscribed))
238 }
239
240 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
241 #[test]
242 fn the_document_carries_the_classes_the_template_carried() {
243 let screen = page_screen(&Standing::Visitor, false);
244
245 assert_eq!(
246 screen.document.body_class.as_deref(),
247 Some("padded-page fan-plus-page")
248 );
249 let rendered = html(&Standing::Visitor, false);
250 assert!(
251 rendered.contains("class=\"padded-page fan-plus-page\""),
252 "{rendered}"
253 );
254 }
255
256 /// The visitor branch, which is the one the template's own comment exists
257 /// to protect: no subscribe control, and both ways to get an account.
258 #[test]
259 fn a_visitor_is_offered_an_account_rather_than_a_dead_end() {
260 let html = html(&Standing::Visitor, false);
261
262 // Asserted on each link's own copy rather than on the bare hrefs: the
263 // site header carries `/join` and `/login` on every page, so an href
264 // alone would pass whatever this block said.
265 //
266 // Each link's own copy, and neither of them nofollowed. The `nofollow`
267 // half is the seal on this being `own_prose`: an untrusted source is
268 // hardened by the renderer and both anchors would carry it.
269 //
270 // `rel="noopener noreferrer"` does survive, from ammonia's default, and
271 // is left alone: it suppresses the referrer and the opener handle, not
272 // the crawl, so it costs nothing an internal link needs.
273 assert!(html.contains(r#"href="/join""#), "{html}");
274 assert!(html.contains(">Create an account</a> to join"), "{html}");
275 assert!(html.contains(r#"href="/login""#), "{html}");
276 assert!(
277 html.contains(">log in</a> if you already have one"),
278 "{html}"
279 );
280 assert!(
281 !html.contains("nofollow"),
282 "the page nofollowed its own links: {html}"
283 );
284 assert!(
285 !html.contains(SUBSCRIBE),
286 "a visitor cannot subscribe, so the control must not be drawn: {html}"
287 );
288 }
289
290 /// A signed-in reader who is not a member gets the pitch and the control.
291 #[test]
292 fn a_reader_who_is_not_a_member_is_offered_the_subscription() {
293 let html = html(&Standing::Unsubscribed, false);
294
295 assert!(html.contains(SUBSCRIBE), "{html}");
296 assert!(html.contains("Join Fan+"), "{html}");
297 assert!(
298 !html.contains(r#"href="/join""#),
299 "somebody signed in does not need an account: {html}"
300 );
301 }
302
303 /// A member is shown their membership and nothing to buy.
304 #[test]
305 fn a_member_is_shown_their_period_and_offered_nothing() {
306 let html = html(
307 &Standing::Member {
308 period_end: Some("March 4, 2027".into()),
309 },
310 false,
311 );
312
313 assert!(html.contains("March 4, 2027"), "{html}");
314 assert!(html.contains("membership is active"), "{html}");
315 assert!(
316 !html.contains(SUBSCRIBE),
317 "a member must not be sold to again: {html}"
318 );
319 assert!(
320 !html.contains("What you get"),
321 "the pitch is for people who have not bought: {html}"
322 );
323 }
324
325 /// Stripe does not always give a period end, and the row is dropped rather
326 /// than rendered empty.
327 #[test]
328 fn a_member_with_no_known_period_end_is_told_the_rest_anyway() {
329 let html = html(&Standing::Member { period_end: None }, false);
330
331 assert!(html.contains("membership is active"), "{html}");
332 assert!(!html.contains("Current period ends"), "{html}");
333 }
334
335 /// `?subscribed=true` is what Stripe sends the reader back with.
336 ///
337 /// Matched on the half of the sentence with no apostrophe in it: the
338 /// description layer escapes one to `&#39;`, so the literal from the source
339 /// never appears in the markup.
340 #[test]
341 fn the_welcome_banner_shows_only_on_the_way_back_from_checkout() {
342 assert!(html(&Standing::Member { period_end: None }, true).contains("now a Fan+ member"));
343 assert!(!html(&Standing::Member { period_end: None }, false).contains("now a Fan+ member"));
344 }
345
346 /// The benefits are what the code grants, and the landing page's Fan+ card
347 /// says the same four. Audited 2026-08-05; this keeps the count honest.
348 #[test]
349 fn the_four_benefits_are_all_stated() {
350 let html = html(&Standing::Unsubscribed, false);
351
352 assert_eq!(BENEFITS.len(), 4);
353 for (name, _) in BENEFITS {
354 assert!(html.contains(name), "{name} missing");
355 }
356 }
357
358 /// `736f45a5`: the wait on the Stripe handoff is said by the description.
359 /// The template spelled it `data-loading-text="Redirecting to Stripe..."`
360 /// on the subscribe button, so the branch that draws that control asserts
361 /// the word replacing it, and every branch asserts the four spellings are
362 /// absent.
363 #[test]
364 fn the_subscribe_control_spells_no_spinner() {
365 assert!(
366 html(&Standing::Unsubscribed, false).contains("data-awaiting="),
367 "{}",
368 html(&Standing::Unsubscribed, false)
369 );
370
371 for rendered in [
372 html(&Standing::Visitor, false),
373 html(&Standing::Unsubscribed, false),
374 html(&Standing::Member { period_end: None }, false),
375 html(&Standing::Unsubscribed, true),
376 ] {
377 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
378 assert!(
379 !rendered.contains(spelling),
380 "{spelling} survives in {rendered}"
381 );
382 }
383 }
384 }
385 }
386