Skip to main content

max / makenotwork

Describe the Fan+ page, which branches three ways on the viewer The fourth public document, and the first that uses the optional user for the page rather than only for the header: a visitor gets the pitch and both ways to get an account, a signed-in reader gets the subscribe control, a member gets their membership's state and nothing to buy. A visitor's request still makes no database query, which is the shipped behaviour on a page whose busiest readers have not signed up. The template's own comment about the visitor branch is carried into the module, because a dead end for exactly the visitor the page is written for is a product decision, not markup. So is the audited rule that the benefits list matches what the code grants. One thing lost, measured before assuming it was this page's: the data-loading-text sentence on the Stripe handoff. 12 of the server's 16 sites say the same two sentences, so it is one idea spelled twelve times rather than a general facility, and Action::leaving is the near miss that does not fit because it is GET. Filed against quasicoherent. Deletes templates/pages/fan_plus.html, FanPlusTemplate, landing::fan_plus_page, FanPlusQuery and the fourteen .fan-plus-page rules: the banner is a Node::banner with Tone::Success, the price is a Figure, the button is an Act.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 13:15 UTC
Signed with PGP, not checked
Commit: fa1a268d50739a07bc8f182ab93ca769c5235674
Parent: e00614c
8 files changed, +348 insertions, -179 deletions
@@ -405,7 +405,7 @@
405 405 .import-page .container,
406 406 .export-page .container { max-width: 800px; margin: 0 auto; }
407 407
408 - .fan-plus-page .container,
408 + .fan-plus-page main,
409 409 .creators-page .container { max-width: 900px; margin: 0 auto; }
410 410
411 411 /* READING COLUMN, the 680px measure every long-form surface uses. Narrower
@@ -3003,51 +3003,14 @@
3003 3003 FAN+ PAGE
3004 3004 =========================================== */
3005 3005
3006 - /* Fan+ subscription page (templates/pages/fan_plus.html). */
3007 - .fan-plus-page h1 { font-size: var(--text-display); margin-bottom: var(--gap-peer); }
3008 - .fan-plus-page h2 { font-size: var(--text-head); margin-top: var(--gap-page); margin-bottom: var(--gap-section); }
3006 + /* The Fan+ page moved to the description layer. Its headings, its section
3007 + spacing, its price callout, its subscribe button, its status box and its
3008 + success banner are all drawn by the design system now: the banner is a
3009 + `Node::banner` with `Tone::Success`, the price is a `Figure`, and the button
3010 + is an `Act`. Nothing here is left to target.
3009 3011
3010 - .fan-plus-page .fan-plus-section { margin-bottom: var(--gap-page); line-height: 1.7; }
3011 - .fan-plus-page .fan-plus-section ul { padding-left: var(--gap-pane); margin: var(--gap-section) 0; }
3012 - .fan-plus-page .fan-plus-section li { margin-bottom: var(--gap-peer); }
3013 -
3014 - .fan-plus-page .price-callout {
3015 - font-size: var(--text-subhead);
3016 - font-family: var(--font-mono);
3017 - margin: var(--gap-pane) 0;
3018 - }
3019 -
3020 - .fan-plus-page .subscribe-form { margin: var(--gap-page) 0; }
3021 -
3022 - .fan-plus-page .subscribe-btn {
3023 - display: inline-block;
3024 - padding: var(--gap-section) var(--gap-page);
3025 - background: var(--action);
3026 - color: var(--primary-light);
3027 - border: none;
3028 - font-size: var(--text-lead);
3029 - cursor: pointer;
3030 - font-family: var(--font-mono);
3031 - }
3032 -
3033 - .fan-plus-page .subscribe-btn:hover { opacity: 0.9; }
3034 -
3035 - .fan-plus-page .status-box {
3036 - padding: var(--gap-pane);
3037 - background: var(--surface-overlay);
3038 - margin: var(--gap-pane) 0;
3039 - }
3040 -
3041 - .fan-plus-page .status-box p { margin: var(--gap-peer) 0; }
3042 -
3043 - .fan-plus-page .success-banner {
3044 - padding: var(--gap-section) var(--gap-pane);
3045 - background: color-mix(in oklch, var(--success) 12%, var(--surface-page));
3046 - margin-bottom: var(--gap-pane);
3047 - font-family: var(--font-mono);
3048 - }
3049 -
3050 - .fan-plus-page .login-link { font-family: var(--font-mono); }
3012 + The measure is on `.fan-plus-page .container` beside `.creators-page` and
3013 + moves to `main` when that page converts too. */
3051 3014
3052 3015 /* ===========================================
3053 3016 PURCHASE PAGE
@@ -46,6 +46,7 @@
46 46 pub mod embeds;
47 47 pub mod export_act;
48 48 pub mod export_portal;
49 + pub mod fan_plus;
49 50 pub mod feeds;
50 51 pub mod forum_memberships;
51 52 pub mod item_files;
@@ -387,7 +388,8 @@
387 388 /// mechanism: those screens resolve nothing per request and take a state built
388 389 /// once at startup. These resolve a session when there is one, so they carry a
389 390 /// per-request viewer and can mint a CSRF token for the form on them.
390 - pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH, use_cases::PATH, policy::PATH];
391 + pub const PUBLIC_DOCUMENT_PATHS: &[&str] =
392 + &[team::PATH, use_cases::PATH, policy::PATH, fan_plus::PATH];
391 393
392 394 /// Every screen's switch name, in the same order as [`PATHS`].
393 395 ///
@@ -577,6 +579,10 @@
577 579 policy::PATH,
578 580 public_document_mount(app, policy::PATH, policy::screen, policy::renderer),
579 581 ),
582 + (
583 + fan_plus::PATH,
584 + public_document_mount(app, fan_plus::PATH, fan_plus::screen, fan_plus::renderer),
585 + ),
580 586 ]
581 587 }
582 588
@@ -178,7 +178,6 @@
178 178 // Use cases
179 179 // Team
180 180 // Fan+
181 - FanPlusTemplate,
182 181 // Creator invite system
183 182 CreatorsTemplate,
184 183 // Email & account
@@ -768,20 +768,6 @@
768 768
769 769 // Fan+
770 770
771 - /// Fan+ subscription page: marketing, subscribe, or manage.
772 - #[derive(Template)]
773 - #[template(path = "pages/fan_plus.html")]
774 - pub struct FanPlusTemplate {
775 - pub csrf_token: CsrfTokenOption,
776 - pub session_user: Option<SessionUser>,
777 - /// Whether the user has an active Fan+ subscription.
778 - pub is_subscribed: bool,
779 - /// Current billing period end (if subscribed).
780 - pub period_end: Option<String>,
781 - /// Whether a `?subscribed=true` query was present (just subscribed).
782 - pub just_subscribed: bool,
783 - }
784 -
785 771 // Blog Pages
786 772
787 773 /// Public blog index for a project.
@@ -22,9 +22,8 @@
22 22 helpers::get_csrf_token,
23 23 routes::custom_domain,
24 24 templates::{
25 - CarouselFrame, CartTemplate, EconomicsTemplate, FanPlusTemplate, IndexTemplate,
26 - LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate,
27 - LibraryTemplate, LoginTemplate,
25 + CarouselFrame, CartTemplate, EconomicsTemplate, IndexTemplate, LandingVelocity,
26 + LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate, LibraryTemplate, LoginTemplate,
28 27 },
29 28 types::{Collection, UserSubscription},
30 29 };
@@ -484,43 +483,3 @@
484 483 </html>"#,
485 484 )
486 485 }
487 -
488 - /// Query params for the Fan+ page.
489 - #[derive(Debug, Deserialize)]
490 - pub(super) struct FanPlusQuery {
491 - pub subscribed: Option<bool>,
492 - }
493 -
494 - /// Render the Fan+ subscription page.
495 - #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
496 - pub(super) async fn fan_plus_page(
497 - State(db): State<PgPool>,
498 - session: Session,
499 - MaybeUserUnverified(maybe_user): MaybeUserUnverified,
500 - ValidatedQuery(query): ValidatedQuery<FanPlusQuery>,
501 - ) -> Result<impl IntoResponse> {
502 - let csrf_token = get_csrf_token(&session).await;
503 -
504 - let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
505 - let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?;
506 - match fan_sub {
507 - Some(sub) if sub.status == "active" => {
508 - let end = sub
509 - .current_period_end
510 - .map(|d| d.format("%B %-d, %Y").to_string());
511 - (true, end)
512 - }
513 - _ => (false, None),
514 - }
515 - } else {
516 - (false, None)
517 - };
518 -
519 - Ok(FanPlusTemplate {
520 - csrf_token,
521 - session_user: maybe_user,
522 - is_subscribed,
523 - period_end,
524 - just_subscribed: query.subscribed.unwrap_or(false),
525 - })
526 - }
@@ -134,7 +134,6 @@
134 134 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
135 135 .route_get("/buy/{item_id}", get(content::buy_page))
136 136 .route_get("/checkout/complete", get(landing::checkout_complete))
137 - .route_get("/fan-plus", get(landing::fan_plus_page))
138 137 // Landing "notify me". CSRF-protected like the other public forms, and
139 138 // rate limited because it is unauthenticated and writes a row.
140 139 .route(
@@ -1,0 +1,331 @@
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 + //! a visitor the pitch, and both ways to get an account
15 + //! a reader, unsubscribed the pitch, and the control that subscribes
16 + //! a reader, subscribed their membership's state, and nothing to buy
17 + //!
18 + //! The visitor branch is the one worth naming. Fan+ needs an account, so a
19 + //! visitor is offered `/join` and `/login` rather than a control they cannot
20 + //! use; the template's own comment says why ("without the second this page is a
21 + //! dead end for exactly the visitor it is written for") and it is carried here
22 + //! because it is a product decision rather than markup.
23 + //!
24 + //! # The database is read only for a reader who might have a subscription
25 + //!
26 + //! A visitor's request makes no query at all, which is the shipped behaviour
27 + //! and worth keeping: this is a marketing page, and the busiest thing about it
28 + //! is people who have not signed up.
29 + //!
30 + //! # One thing lost, and it is 12 sites rather than this one
31 + //!
32 + //! `data-loading-text="Redirecting to Stripe..."`. [`Action::awaiting`] says
33 + //! that a wait is happening and the design system draws it, but nothing carries
34 + //! the sentence. Measured before assuming it was this page's problem: 12 of the
35 + //! server's 16 `data-loading-text` sites say "Redirecting to Stripe..." or
36 + //! "Opening Stripe...", so the attribute is not a general facility for wait
37 + //! wording -- it is one idea, "this control hands you off to the payment
38 + //! provider", spelled twelve times.
39 + //!
40 + //! `Action::leaving` is the near miss and does not fit: it is `Method::Get`,
41 + //! and every one of the twelve is a POST to our own route that answers with a
42 + //! redirect. Filed against quasicoherent rather than worked around with a
43 + //! hand-written attribute inside a described form.
44 +
45 + use makeover_layout as layout;
46 + use quasi_router::screen::{Figure, Row};
47 + use quasi_router::{
48 + Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
49 + };
50 + use quasi_webview::Webview;
51 +
52 + use crate::db;
53 +
54 + /// The address, registered whole. See [`super::public_document_mount`].
55 + pub const PATH: &str = "/fan-plus";
56 +
57 + /// Where the subscription is actually started. Not this screen's route: it is
58 + /// the Stripe checkout handoff, which already exists and already carries the
59 + /// CSRF posture it needs.
60 + const SUBSCRIBE: &str = "/stripe/fan-plus";
61 +
62 + /// The page's own region, and what the skip link points at.
63 + pub const PAGE_REGION: &str = "fan-plus";
64 +
65 + const MEASURE: layout::Measure = layout::Measure::Wide;
66 +
67 + /// What the membership costs, in whole dollars per month.
68 + const PRICE: &str = "$8";
69 +
70 + /// What a member gets.
71 + ///
72 + /// **Keep this list and the Fan+ card on the landing page identical, and keep
73 + /// both to what the code grants.** Audited 2026-08-05, carried over from the
74 + /// template's comment because it is a rule rather than a note: a benefit listed
75 + /// here and not granted by the code is a promise nobody implemented.
76 + const BENEFITS: &[(&str, &str)] = &[
77 + (
78 + "$5 monthly credit",
79 + "A promo code delivered by email each billing cycle, usable toward any purchase on the platform",
80 + ),
81 + ("+ badge", "Displayed next to your name on your forum posts"),
82 + (
83 + "Forum signatures",
84 + "A signature block rendered under everything you post",
85 + ),
86 + ("Image embeds", "Post images in forum threads"),
87 + ];
88 +
89 + /// What this request knows about the reader's membership.
90 + enum Standing {
91 + /// Nobody is signed in. The page is a pitch plus a way to get an account.
92 + Visitor,
93 + /// Signed in, not a member.
94 + Unsubscribed,
95 + /// Signed in and paying, with the date the current period ends when Stripe
96 + /// has told us one.
97 + Member { period_end: Option<String> },
98 + }
99 +
100 + /// The page.
101 + pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
102 + // Moved out of the request rather than borrowed: the signature is quasi's,
103 + // so the request arrives owned.
104 + let carried = request.carried;
105 + let just_subscribed = carried
106 + .get("subscribed")
107 + .is_some_and(|value| value.trim() == "true");
108 +
109 + let standing = standing(viewer)?;
110 +
111 + Ok(page_screen(&standing, just_subscribed).into())
112 + }
113 +
114 + /// Read the reader's membership, if there is a reader.
115 + fn standing(viewer: &super::Viewer) -> Result<Standing, RouteError> {
116 + let Some(user) = viewer.user.as_ref() else {
117 + return Ok(Standing::Visitor);
118 + };
119 +
120 + let subscription = viewer
121 + .block_on(db::fan_plus::get_fan_plus_by_user(&viewer.app.db, user.id))
122 + .map_err(|_| RouteError::internal("your membership could not be read"))?;
123 +
124 + Ok(match subscription {
125 + Some(sub) if sub.status == "active" => Standing::Member {
126 + period_end: sub
127 + .current_period_end
128 + .map(|end| end.format("%B %-d, %Y").to_string()),
129 + },
130 + _ => Standing::Unsubscribed,
131 + })
132 + }
133 +
134 + /// The whole document: the title, the measure, the body.
135 + fn page_screen(standing: &Standing, just_subscribed: bool) -> Described {
136 + let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Fan+"));
137 +
138 + if just_subscribed {
139 + page = page.with(Node::banner(
140 + layout::Tone::Success,
141 + "You're now a Fan+ member. Welcome.",
142 + ));
143 + }
144 +
145 + page = match standing {
146 + Standing::Member { period_end } => membership(page, period_end.as_deref()),
147 + Standing::Unsubscribed => {
148 + pitch(page).with(Node::act("Join Fan+", Action::post(SUBSCRIBE).awaiting()))
149 + }
150 + Standing::Visitor => {
151 + // Fan+ needs an account, so a visitor gets both paths: the one for
152 + // people who already have one and the one for people who do not.
153 + // Without the second this page is a dead end for exactly the
154 + // visitor it is written for.
155 + pitch(page)
156 + .with(Node::act(
157 + "Create an account",
158 + Action::get("/join").navigating(),
159 + ))
160 + .with(Node::act("Log in", Action::get("/login").navigating()))
161 + }
162 + };
163 +
164 + Described::single("Fan+ - Makenotwork")
165 + .measured(MEASURE)
166 + .documented(
167 + Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])),
168 + )
169 + .summarised(
170 + "Support the platform and get $5 of credit back every month, plus forum badges, \
171 + signatures and image embeds.",
172 + )
173 + .with(page)
174 + }
175 +
176 + /// What a member is shown: the state of the thing they are paying for.
177 + fn membership(page: Slot, period_end: Option<&str>) -> Slot {
178 + let mut page = page.with(Node::text("Your Fan+ membership is active."));
179 +
180 + if let Some(end) = period_end {
181 + page = page.with(Node::text(format!("Current period ends: {end}")));
182 + }
183 +
184 + page.with(Node::text(
185 + "You'll receive a $5 credit code each billing cycle via email.",
186 + ))
187 + }
188 +
189 + /// What somebody who is not a member is shown, whether or not they have an
190 + /// account. The two branches differ only in what they are offered afterwards.
191 + fn pitch(page: Slot) -> Slot {
192 + page.with(Node::text(
193 + "Support the platform and get something back every month.",
194 + ))
195 + .with(Node::section("What you get"))
196 + .with(Node::list(
197 + BENEFITS
198 + .iter()
199 + .map(|(name, detail)| Row::new(*name).secondary(*detail)),
200 + ))
201 + .with(Node::stats([Figure::new(PRICE, "per month")]))
202 + .with(Node::text(
203 + "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \
204 + platform's development and operations, while getting real value back each month.",
205 + ))
206 + }
207 +
208 + /// The document this screen is drawn in.
209 + #[must_use]
210 + pub fn renderer(viewer: &super::Viewer) -> Webview {
211 + Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
212 + "{}{}",
213 + crate::shell::skip_link(PAGE_REGION),
214 + crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
215 + )))
216 + }
217 +
218 + #[cfg(test)]
219 + mod tests {
220 + use super::*;
221 +
222 + fn html(standing: &Standing, just_subscribed: bool) -> String {
223 + use quasi_axum::Serves as _;
224 +
225 + Webview::new().screen(&page_screen(standing, just_subscribed))
226 + }
227 +
228 + /// `2790e5c4`. Both classes were on the body already, so this is a copy.
229 + #[test]
230 + fn the_document_carries_the_classes_the_template_carried() {
231 + let screen = page_screen(&Standing::Visitor, false);
232 +
233 + assert_eq!(
234 + screen.document.body_class.as_deref(),
235 + Some("padded-page fan-plus-page")
236 + );
237 + }
238 +
239 + /// The visitor branch, which is the one the template's own comment exists
240 + /// to protect: no subscribe control, and both ways to get an account.
241 + #[test]
242 + fn a_visitor_is_offered_an_account_rather_than_a_dead_end() {
243 + let html = html(&Standing::Visitor, false);
244 +
245 + assert!(html.contains(r#"href="/join""#), "{html}");
246 + assert!(html.contains(r#"href="/login""#), "{html}");
247 + assert!(
248 + !html.contains(SUBSCRIBE),
249 + "a visitor cannot subscribe, so the control must not be drawn: {html}"
250 + );
251 + }
252 +
253 + /// A signed-in reader who is not a member gets the pitch and the control.
254 + #[test]
255 + fn a_reader_who_is_not_a_member_is_offered_the_subscription() {
256 + let html = html(&Standing::Unsubscribed, false);
257 +
258 + assert!(html.contains(SUBSCRIBE), "{html}");
259 + assert!(html.contains("Join Fan+"), "{html}");
260 + assert!(
261 + !html.contains(r#"href="/join""#),
262 + "somebody signed in does not need an account: {html}"
263 + );
264 + }
265 +
266 + /// A member is shown their membership and nothing to buy.
267 + #[test]
268 + fn a_member_is_shown_their_period_and_offered_nothing() {
269 + let html = html(
270 + &Standing::Member {
271 + period_end: Some("March 4, 2027".into()),
272 + },
273 + false,
274 + );
275 +
276 + assert!(html.contains("March 4, 2027"), "{html}");
277 + assert!(html.contains("membership is active"), "{html}");
278 + assert!(
279 + !html.contains(SUBSCRIBE),
280 + "a member must not be sold to again: {html}"
281 + );
282 + assert!(
283 + !html.contains("What you get"),
284 + "the pitch is for people who have not bought: {html}"
285 + );
286 + }
287 +
288 + /// Stripe does not always give a period end, and the row is dropped rather
289 + /// than rendered empty.
290 + #[test]
291 + fn a_member_with_no_known_period_end_is_told_the_rest_anyway() {
292 + let html = html(&Standing::Member { period_end: None }, false);
293 +
294 + assert!(html.contains("membership is active"), "{html}");
295 + assert!(!html.contains("Current period ends"), "{html}");
296 + }
297 +
298 + /// `?subscribed=true` is what Stripe sends the reader back with.
299 + ///
300 + /// Matched on the half of the sentence with no apostrophe in it: the
301 + /// description layer escapes one to `&#39;`, so the literal from the source
302 + /// never appears in the markup.
303 + #[test]
304 + fn the_welcome_banner_shows_only_on_the_way_back_from_checkout() {
305 + assert!(html(&Standing::Member { period_end: None }, true).contains("now a Fan+ member"));
306 + assert!(!html(&Standing::Member { period_end: None }, false).contains("now a Fan+ member"));
307 + }
308 +
309 + /// The benefits are what the code grants, and the landing page's Fan+ card
310 + /// says the same four. Audited 2026-08-05; this keeps the count honest.
311 + #[test]
312 + fn the_four_benefits_are_all_stated() {
313 + let html = html(&Standing::Unsubscribed, false);
314 +
315 + assert_eq!(BENEFITS.len(), 4);
316 + for (name, _) in BENEFITS {
317 + assert!(html.contains(name), "{name} missing");
318 + }
319 + }
320 +
321 + /// `736f45a5`: the wait on the Stripe handoff is said by the description.
322 + #[test]
323 + fn the_subscribe_control_spells_no_spinner() {
324 + let html = html(&Standing::Unsubscribed, false);
325 +
326 + assert!(html.contains("data-awaiting="), "{html}");
327 + for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
328 + assert!(!html.contains(spelling), "{spelling} survives in {html}");
329 + }
330 + }
331 + }
@@ -1,74 +1,0 @@
1 - {% extends "base.html" %}
2 -
3 - {% block title %}Fan+ - Makenotwork{% endblock %}
4 - {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} fan-plus-page"{% endblock %}
5 -
6 - {% block content %}
7 - {% include "partials/site_header.html" %}
8 -
9 - <div class="container">
10 - <h1 class="page-title">Fan+</h1>
11 -
12 - {% if just_subscribed %}
13 - <div class="success-banner">
14 - You're now a Fan+ member. Welcome.
15 - </div>
16 - {% endif %}
17 -
18 - {% if is_subscribed %}
19 - <div class="status-box">
20 - <p><strong>Your Fan+ membership is active.</strong></p>
21 - {% if let Some(end) = period_end %}
22 - <p>Current period ends: {{ end }}</p>
23 - {% endif %}
24 - <p>You'll receive a $5 credit code each billing cycle via email.</p>
25 - </div>
26 - {% else %}
27 - <div class="page-intro">
28 - Support the platform and get something back every month.
29 - </div>
30 -
31 - <div class="fan-plus-section">
32 - <h2 class="section-header">What you get</h2>
33 - {# Keep this list and the Fan+ card on the landing page identical,
34 - and keep both to what the code grants. Audited 2026-08-05. #}
35 - <ul>
36 - <li><strong>$5 monthly credit</strong>: a promo code delivered by email each billing cycle, usable toward any purchase on the platform</li>
37 - <li><strong>+ badge</strong>: displayed next to your name on your forum posts</li>
38 - <li><strong>Forum signatures</strong>: a signature block rendered under everything you post</li>
39 - <li><strong>Image embeds</strong>: post images in forum threads</li>
40 - </ul>
41 - </div>
42 -
43 - <div class="price-callout">$8/month</div>
44 -
45 - <div class="fan-plus-section">
46 - <p>
47 - Makenotwork is built on 0% platform fees. Fan+ is how you directly
48 - support the platform's development and operations, while getting real
49 - value back each month.
50 - </p>
51 - </div>
52 -
53 - {% if session_user.is_some() %}
54 - <div class="subscribe-form">
55 - <form method="post" action="/stripe/fan-plus">
56 - {% if let Some(token) = csrf_token %}
57 - <input type="hidden" name="_csrf" value="{{ token }}">
58 - {% endif %}
59 - <button type="submit" class="subscribe-btn" data-loading-text="Redirecting to Stripe...">Join Fan+</button>
60 - </form>
61 - </div>
62 - {% else %}
63 - {# Fan+ needs an account, so an anonymous visitor gets both paths: the
64 - one for people who already have one and the one for people who do
65 - not. Without the second this page is a dead end for exactly the
66 - visitor it is written for. #}
67 - <p class="login-link">
68 - <a href="/join">Create an account</a> to join, or
69 - <a href="/login">log in</a> if you already have one.
70 - </p>
71 - {% endif %}
72 - {% endif %}
73 - </div>
74 - {% endblock %}