Skip to main content

max / makenotwork

25.2 KB · 676 lines History Blame Raw
1 //! Landing, authentication, and static public pages.
2
3 use askama::Template as _;
4
5 use crate::extractors::ValidatedQuery;
6 use axum::{
7 Form,
8 extract::{Query, State},
9 http::HeaderMap,
10 response::{IntoResponse, Redirect, Response},
11 };
12 use serde::Deserialize;
13 use sqlx::PgPool;
14 use tower_sessions::Session;
15
16 use crate::{
17 AppCaches, Billing, Integrations,
18 auth::{AuthUser, MaybeUserUnverified},
19 config::Config,
20 constants, db,
21 error::{AppError, Result},
22 helpers::get_csrf_token,
23 routes::custom_domain,
24 templates::{
25 CarouselFrame, CartTemplate, EconomicsTemplate, FanPlusTemplate, FeeCalculatorPartial,
26 IndexTemplate, LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate,
27 LibraryTemplate, LoginTemplate, PolicyTemplate, PricingTemplate, TeamTemplate,
28 UseCasesTemplate,
29 },
30 types::{Collection, DiscoverItem, UserSubscription},
31 };
32
33 /// Render the landing page, or redirect authenticated users to the library.
34 ///
35 /// If the Host header belongs to a verified custom domain, renders that user's
36 /// profile instead (the fallback handler only catches paths that don't match
37 /// any named route, so `/` needs to be handled here).
38 /// Outcome of a no-JS notify submission, round-tripped through the redirect.
39 ///
40 /// The JS path renders its own status inline and never sets this; it exists so
41 /// a visitor without JS gets told what happened instead of landing back on an
42 /// apparently unchanged page.
43 #[derive(Deserialize)]
44 pub(super) struct IndexQuery {
45 notify: Option<String>,
46 }
47
48 #[tracing::instrument(skip_all, name = "landing::index")]
49 #[allow(clippy::too_many_arguments)]
50 pub(super) async fn index(
51 State(db): State<PgPool>,
52 State(caches): State<AppCaches>,
53 State(integrations): State<Integrations>,
54 State(config): State<Config>,
55 State(billing): State<Billing>,
56 Query(q): Query<IndexQuery>,
57 headers: HeaderMap,
58 session: Session,
59 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
60 ) -> Result<Response> {
61 // Check for custom domain, delegate to the custom domain handler
62 if let Some(response) = custom_domain::try_handle(
63 &db,
64 &caches,
65 &integrations,
66 &config,
67 &headers,
68 "/",
69 &session,
70 maybe_user.as_ref(),
71 )
72 .await
73 {
74 return Ok(response);
75 }
76
77 match maybe_user {
78 Some(_) => Ok(Redirect::to("/library").into_response()),
79 None => {
80 let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
81 let total_items = db::items::count_public_listed(&db).await?;
82
83 // "Last shipped" velocity line: most recent published, landing-
84 // flagged post on the changelog project. Read once per render; the
85 // line is suppressed entirely when nothing qualifies (no
86 // placeholder), matching the runway disclosure's no-fabrication rule.
87 let last_shipped =
88 db::blog_posts::get_landing_changelog_post(&db, constants::CHANGELOG_PROJECT_SLUG)
89 .await?
90 .and_then(|post| {
91 post.published_at.map(|published_at| LandingVelocity {
92 title: post.title,
93 date: published_at.format("%b %d, %Y").to_string(),
94 href: format!("/changelog/{}", post.slug),
95 })
96 });
97
98 // Surface remaining founder slots only when close enough to feel
99 // scarce. 200 is "last chunk", enough warning to convert, not so
100 // early that the number stays prominent for months.
101 let founder_window_open = config.creator_pricing.founder_window_open;
102 const FOUNDER_CAP: u32 = 1_000;
103 const URGENCY_THRESHOLD: u32 = 200;
104 let founder_slots_remaining = if founder_window_open
105 && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD)
106 {
107 Some(FOUNDER_CAP.saturating_sub(total_creators))
108 } else {
109 None
110 };
111
112 // Real captures of testnot.work, all three from one run of
113 // scripts/capture-landing-carousel.mjs. Re-shoot with that script
114 // rather than by hand: the three agree on viewport, scale, crop and
115 // aspect by construction, and shooting one of them alone is what
116 // makes a carousel look wrong.
117 //
118 // The alt text describes what is actually in each frame, not what
119 // the page it came from contains. Frame 1's crop holds the shop and
120 // its cover art; the prices sit below the fold, so it does not claim
121 // them.
122 let landing_carousel = vec![
123 CarouselFrame::new(
124 "/static/images/shots/storefront.webp",
125 "A creator's storefront on Makenotwork, titled and described, above a row of their work in cover art",
126 )
127 .with_caption("Your storefront: sell anything digital"),
128 CarouselFrame::new(
129 "/static/images/shots/item.webp",
130 "An item page showing its cover art, an $8 price, and a buy button, beside what the purchase includes",
131 )
132 .with_caption("Every sale is yours. 0% platform fee"),
133 CarouselFrame::new(
134 "/static/images/shots/library.webp",
135 "A buyer's library listing what they have bought, each row with its creator, type, purchase date and a button to open it",
136 )
137 .with_caption("Buyers keep what they bought. One-click export"),
138 ];
139
140 Ok(IndexTemplate {
141 csrf_token: get_csrf_token(&session).await,
142 host_url: config.host_url.clone(),
143 total_creators,
144 total_items: total_items as u32,
145 founder_window_open,
146 founder_slots_remaining,
147 tier_prices: billing.tier_prices.clone(),
148 landing_carousel,
149 last_shipped,
150 notify_ok: match q.notify.as_deref() {
151 Some("ok") => Some(true),
152 Some("invalid") => Some(false),
153 _ => None,
154 },
155 }
156 .into_response())
157 }
158 }
159 }
160
161 /// Render the authenticated user's library with inline purchases tab.
162 #[tracing::instrument(skip_all, name = "landing::library")]
163 pub(super) async fn library(
164 State(db): State<PgPool>,
165 State(config): State<Config>,
166 session: Session,
167 AuthUser(user): AuthUser,
168 ) -> Result<impl IntoResponse> {
169 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
170 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
171 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
172 let has_mt_memberships = config.integrations.mt_base_url.is_some();
173
174 // The shown panel is rendered here rather than fetched, which is what the
175 // page's `{% include %}` did before the strip was described and is why this
176 // conversion changes the request count by zero. `6b24f2df`.
177 let shown = LibraryPurchasesTabTemplate {
178 purchases,
179 subscriptions,
180 }
181 .render()
182 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
183
184 let can_create_projects = user.can_create_projects;
185 Ok(LibraryTemplate {
186 csrf_token: get_csrf_token(&session).await,
187 session_user: Some(user),
188 tabs: crate::quasi::library_tabs::html(&shown, has_mt_memberships, can_create_projects),
189 })
190 }
191
192 /// Query parameters for the cart page.
193 #[derive(Deserialize)]
194 pub(super) struct CartQuery {
195 pub checkout: Option<String>,
196 }
197
198 /// Render the shopping cart page with items grouped by seller.
199 #[tracing::instrument(skip_all, name = "landing::cart_page")]
200 pub(super) async fn cart_page(
201 State(db): State<PgPool>,
202 session: Session,
203 AuthUser(user): AuthUser,
204 ValidatedQuery(query): ValidatedQuery<CartQuery>,
205 ) -> Result<impl IntoResponse> {
206 use crate::templates::CartSellerGroup;
207 use std::collections::BTreeMap;
208
209 let cart_items = db::cart::get_cart_items(&db, user.id).await?;
210
211 // The buyer's own currency is only a fact once they have connected Stripe;
212 // otherwise `settlement_currency` is just the column default and says
213 // nothing about the card they will pay with.
214 let buyer = db::users::get_user_by_id(&db, user.id).await?;
215 let buyer_currency = buyer
216 .as_ref()
217 .filter(|u| u.stripe_account_id.is_some())
218 .map(|u| u.settlement_currency);
219 let buyer_conversion = buyer
220 .as_ref()
221 .map(|u| u.conversion_preference)
222 .unwrap_or_default();
223
224 // Group by seller
225 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
226 for item in &cart_items {
227 groups
228 .entry(item.seller_id.to_string())
229 .or_default()
230 .push(item.clone());
231 }
232
233 let seller_groups: Vec<CartSellerGroup> = groups
234 .into_iter()
235 .map(|(seller_id_str, items)| {
236 let subtotal_cents: i64 = items
237 .iter()
238 .map(|i| i64::from(i.effective_price_cents()))
239 .sum();
240 let item_count = items.len();
241 // Savings: buying N items in one session saves (N-1) * $0.30
242 let savings_cents = if item_count > 1 {
243 (item_count as i32 - 1) * 30
244 } else {
245 0
246 };
247 let seller_username = items
248 .first()
249 .map(|i| i.creator_username.clone())
250 .unwrap_or_default();
251 let stripe_ready = items
252 .first()
253 .is_some_and(|i| i.seller_stripe_account_id.is_some() && i.seller_charges_enabled);
254
255 // One group is one seller, so the first item's currency is the
256 // group's. An empty group cannot reach here (groups are built by
257 // grouping items), but default rather than panic if that changes.
258 let currency = items
259 .first()
260 .map(|i| i.settlement_currency)
261 .unwrap_or_default();
262
263 CartSellerGroup {
264 offer_conversion_choice: crate::templates::cart_conversion_applies(
265 buyer_currency,
266 currency,
267 ),
268 conversion: buyer_conversion,
269 seller_username,
270 seller_id: seller_id_str,
271 stripe_ready,
272 items,
273 subtotal_cents,
274 item_count,
275 savings_cents,
276 currency,
277 }
278 })
279 .collect();
280
281 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
282
283 // Wishlist suggestions: items in wishlist but not in cart
284 let wishlist = db::wishlists::get_wishlist(&db, user.id).await?;
285 let cart_item_ids: std::collections::HashSet<_> =
286 cart_items.iter().map(|i| i.item_id).collect();
287 let wishlist_suggestions: Vec<_> = wishlist
288 .into_iter()
289 .filter(|w| !cart_item_ids.contains(&w.item_id))
290 .take(10)
291 .collect();
292
293 let offer_conversion_choice = seller_groups.iter().any(|g| g.offer_conversion_choice);
294
295 Ok(CartTemplate {
296 csrf_token: get_csrf_token(&session).await,
297 session_user: Some(user),
298 seller_groups,
299 wishlist_suggestions,
300 total_items,
301 checkout_status: query.checkout.unwrap_or_default(),
302 offer_conversion_choice,
303 conversion: buyer_conversion,
304 })
305 }
306
307 /// HTMX partial: library purchases tab (includes subscriptions).
308 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
309 pub(super) async fn library_tab_purchases(
310 State(db): State<PgPool>,
311 AuthUser(user): AuthUser,
312 ) -> Result<impl IntoResponse> {
313 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
314 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
315 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
316 Ok(LibraryPurchasesTabTemplate {
317 purchases,
318 subscriptions,
319 })
320 }
321
322 /// HTMX partial: library feed tab.
323 #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
324 pub(super) async fn library_tab_feed(
325 State(db): State<PgPool>,
326 AuthUser(user): AuthUser,
327 ValidatedQuery(query): ValidatedQuery<super::feed::FeedQuery>,
328 ) -> Result<impl IntoResponse> {
329 use crate::templates::LibraryFeedTabTemplate;
330
331 // Clamp the upper bound like every sibling paginator (feed/discover/
332 // admin/issues): an unbounded `?page=` would issue a giant SQL OFFSET
333 // deep-scan. Widen to i64 BEFORE multiplying to avoid u32 overflow.
334 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
335 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
336
337 let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32;
338 let total_pages =
339 (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
340
341 let db_items = db::follows::get_followed_feed_items(
342 &db,
343 user.id,
344 constants::FEED_PAGE_SIZE as i64,
345 offset,
346 )
347 .await?;
348
349 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
350
351 // Compute the "showing X–Y" labels in i64 (saturating) to avoid the u32
352 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`.
353 let showing_start = if total_items == 0 {
354 0
355 } else {
356 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
357 };
358 let showing_end = offset
359 .saturating_add(constants::FEED_PAGE_SIZE as i64)
360 .min(total_items as i64)
361 .clamp(0, u32::MAX as i64) as u32;
362 let pagination_range = super::pagination::build_pagination_range(page, total_pages);
363
364 Ok(LibraryFeedTabTemplate {
365 items,
366 total_items,
367 current_page: page,
368 total_pages,
369 pagination_range,
370 showing_start,
371 showing_end,
372 })
373 }
374
375 /// HTMX partial: library collections tab (includes wishlists).
376 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
377 pub(super) async fn library_tab_collections(
378 State(db): State<PgPool>,
379 AuthUser(user): AuthUser,
380 ) -> Result<impl IntoResponse> {
381 let db_collections = db::collections::get_collections_by_user(&db, user.id).await?;
382 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
383 let wishlists = db::wishlists::get_wishlist(&db, user.id).await?;
384 Ok(LibraryCollectionsTabTemplate {
385 collections,
386 username: user.username.to_string(),
387 wishlists,
388 })
389 }
390
391 /// Query params for the login page.
392 #[derive(Deserialize)]
393 pub(crate) struct LoginQuery {
394 /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
395 /// the visitor landed on login instead of the page they requested.
396 pub gate: Option<String>,
397 /// Set by the SSO callback when delegated login fails; shown as an error.
398 pub sso_error: Option<String>,
399 }
400
401 /// Render the login page.
402 #[tracing::instrument(skip_all, name = "landing::login_page")]
403 pub(crate) async fn login_page(
404 State(config): State<Config>,
405 session: Session,
406 ValidatedQuery(query): ValidatedQuery<LoginQuery>,
407 ) -> impl IntoResponse {
408 let sso_enabled = config.sso.is_some();
409 let notice = match query.gate.as_deref() {
410 Some("fan_plus_or_creator") => Some(
411 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
412 .to_string(),
413 ),
414 _ => None,
415 };
416 LoginTemplate {
417 csrf_token: get_csrf_token(&session).await,
418 prefill_login: String::new(),
419 error: query.sso_error,
420 notice,
421 sso_enabled,
422 }
423 }
424
425 /// Render the interactive fee calculator page.
426 ///
427 /// The dials open on the positions in `assumptions.toml`, or on whatever the
428 /// query string carries, so a calculator URL can be shared and lands on the
429 /// same scenario.
430 #[tracing::instrument(skip_all, name = "landing::pricing_page")]
431 pub(super) async fn pricing_page(
432 State(payments): State<Billing>,
433 State(config): State<Config>,
434 ValidatedQuery(q): ValidatedQuery<PricingCompareQuery>,
435 session: Session,
436 ) -> impl IntoResponse {
437 let inputs = q.resolve(&payments);
438 PricingTemplate {
439 csrf_token: get_csrf_token(&session).await,
440 founder_window_open: config.creator_pricing.founder_window_open,
441 tier_prices: payments.tier_prices.clone(),
442 other_pct_display: fmt_dial(inputs.other_pct * 100.0),
443 other_per_sale_display: fmt_dial(inputs.other_per_sale),
444 outcome: payments.fee_calculator.compute(inputs),
445 inputs,
446 }
447 }
448
449 #[derive(Deserialize)]
450 pub(super) struct NotifyForm {
451 email: String,
452 }
453
454 /// POST /notify: the landing page's "notify me" capture.
455 ///
456 /// The form posts here directly, so it works with JS off. The page script
457 /// intercepts the submit and posts the same form encoding to the same route,
458 /// which makes it a genuine enhancement (async, inline status, no reload)
459 /// rather than the only path. Before this the form had no action and no
460 /// method, so a browser without JS submitted a GET to `/` and the address was
461 /// dropped without a word to anyone.
462 ///
463 /// Storage is `email_signups`, which already existed for exactly this with a
464 /// `source` column, duplicate collapsing, and an admin view at
465 /// /admin/signups, so nobody has to be told where the addresses went.
466 ///
467 /// A rejected address redirects rather than erroring: this is the last thing
468 /// on the landing page, and a 422 on a marketing form is a worse outcome than
469 /// a sentence saying the address looked wrong.
470 #[tracing::instrument(skip_all, name = "landing::notify")]
471 pub(super) async fn notify(
472 State(db): State<PgPool>,
473 Form(form): Form<NotifyForm>,
474 ) -> Result<impl IntoResponse> {
475 let Ok(email) = db::Email::new(&form.email) else {
476 return Ok(Redirect::to("/?notify=invalid#notify-form"));
477 };
478 db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
479 Ok(Redirect::to("/?notify=ok#notify-form"))
480 }
481
482 /// Render a dial value for an input box: no trailing zeros on a whole number,
483 /// at most two decimals otherwise. `12.6`, `0.30` and `25` all read as typed.
484 fn fmt_dial(v: f64) -> String {
485 let s = format!("{v:.2}");
486 s.trim_end_matches('0').trim_end_matches('.').to_string()
487 }
488
489 /// Query for the calculator, shared by the full page and the HTMX recompute.
490 ///
491 /// Every field arrives as a string (an input can be cleared to empty) and is
492 /// parsed leniently: a blank or garbage dial falls back to its default rather
493 /// than 400ing, and `FeeCalculator::sanitize` clamps the rest into range.
494 #[derive(Deserialize)]
495 pub(super) struct PricingCompareQuery {
496 item_price: Option<String>,
497 sales: Option<String>,
498 tier: Option<String>,
499 /// Whole percent as typed (`12.6`), converted to a fraction here.
500 other_pct: Option<String>,
501 other_per_sale: Option<String>,
502 }
503
504 impl PricingCompareQuery {
505 /// Merge the query string over the configured defaults and clamp.
506 fn resolve(&self, payments: &Billing) -> crate::fee_calculator::Inputs {
507 let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.trim().parse::<f64>().ok());
508 let basic = payments.tier_prices.basic_std as f64;
509 let mut inputs = payments.fee_calculator.default_inputs(basic);
510 if let Some(v) = parse(&self.item_price) {
511 inputs.item_price = v;
512 }
513 if let Some(v) = parse(&self.sales) {
514 inputs.sales_per_month = v;
515 }
516 if let Some(v) = parse(&self.other_pct) {
517 inputs.other_pct = v / 100.0;
518 }
519 if let Some(v) = parse(&self.other_per_sale) {
520 inputs.other_per_sale = v;
521 }
522 inputs.tier_cost = parse(&self.tier).filter(|v| *v >= 0.0).unwrap_or(basic);
523 payments.fee_calculator.sanitize(inputs)
524 }
525 }
526
527 /// Recompute the calculator server-side and return the results partial.
528 /// Pure computation, no auth, no state change, a GET so no CSRF is needed.
529 #[tracing::instrument(skip_all, name = "landing::pricing_compare")]
530 pub(super) async fn pricing_compare(
531 State(payments): State<Billing>,
532 ValidatedQuery(q): ValidatedQuery<PricingCompareQuery>,
533 ) -> impl IntoResponse {
534 FeeCalculatorPartial {
535 outcome: payments.fee_calculator.compute(q.resolve(&payments)),
536 }
537 }
538
539 /// Render the platform-economics + runway disclosure page.
540 ///
541 /// Served top-level at `/economics` alongside the other landing pages
542 /// (the retired markdown source used to live at `/docs/economics`, which
543 /// now 301s here). Renders as Askama (not docengine markdown) so it can
544 /// carry live figures from the database. The two count queries are cheap
545 /// (each is a single `SELECT COUNT(*)` against an indexed status column);
546 /// no caching needed at current load.
547 #[tracing::instrument(skip_all, name = "landing::economics_page")]
548 pub(super) async fn economics_page(
549 State(db): State<PgPool>,
550 State(payments): State<Billing>,
551 session: Session,
552 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
553 ) -> Result<impl IntoResponse> {
554 let paying_creators = crate::db::creator_tiers::count_active_paying(&db).await?;
555 let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&db).await?;
556 Ok(EconomicsTemplate {
557 csrf_token: get_csrf_token(&session).await,
558 session_user: maybe_user,
559 runway_config: payments.runway_config.clone(),
560 paying_creators,
561 trialing_or_grace,
562 })
563 }
564
565 /// Lightweight checkout success page for app-initiated Stripe flows.
566 /// No auth required; the app polls for subscription status independently.
567 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
568 pub(super) async fn checkout_complete() -> impl IntoResponse {
569 axum::response::Html(
570 r#"<!DOCTYPE html>
571 <html lang="en">
572 <head>
573 <meta charset="UTF-8">
574 <meta name="viewport" content="width=device-width, initial-scale=1.0">
575 <title>Payment Complete | Makenotwork</title>
576 <!-- Standalone head, so the three sheets are listed here rather than
577 coming from crate::shell. style.css reads the bevel pair out of
578 layout.css, so it has to load after it. -->
579 <link rel="stylesheet" href="/static/geometry.css">
580 <link rel="stylesheet" href="/static/layout.css">
581 <link rel="stylesheet" href="/static/style.css">
582 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
583 </head>
584 <body>
585 <main id="main-content">
586 <div class="error-page">
587 <div class="error-container">
588 <h1 class="error-title">Payment complete</h1>
589 <p class="error-message">You can close this tab and return to the app.</p>
590 </div>
591 </div>
592 </main>
593 </body>
594 </html>"#,
595 )
596 }
597
598 /// Render the use cases page.
599 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
600 pub(super) async fn use_cases_page(
601 State(payments): State<Billing>,
602 session: Session,
603 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
604 ) -> impl IntoResponse {
605 UseCasesTemplate {
606 csrf_token: get_csrf_token(&session).await,
607 session_user: maybe_user,
608 tier_prices: payments.tier_prices.clone(),
609 }
610 }
611
612 /// Render the team page.
613 #[tracing::instrument(skip_all, name = "landing::team_page")]
614 pub(super) async fn team_page(
615 session: Session,
616 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
617 ) -> impl IntoResponse {
618 TeamTemplate {
619 csrf_token: get_csrf_token(&session).await,
620 session_user: maybe_user,
621 }
622 }
623
624 /// Render the content policy page.
625 #[tracing::instrument(skip_all, name = "landing::policy_page")]
626 pub(super) async fn policy_page(
627 session: Session,
628 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
629 ) -> impl IntoResponse {
630 let csrf_token = get_csrf_token(&session).await;
631 PolicyTemplate {
632 csrf_token,
633 session_user: maybe_user,
634 }
635 }
636
637 /// Query params for the Fan+ page.
638 #[derive(Debug, Deserialize)]
639 pub(super) struct FanPlusQuery {
640 pub subscribed: Option<bool>,
641 }
642
643 /// Render the Fan+ subscription page.
644 #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
645 pub(super) async fn fan_plus_page(
646 State(db): State<PgPool>,
647 session: Session,
648 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
649 ValidatedQuery(query): ValidatedQuery<FanPlusQuery>,
650 ) -> Result<impl IntoResponse> {
651 let csrf_token = get_csrf_token(&session).await;
652
653 let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
654 let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?;
655 match fan_sub {
656 Some(sub) if sub.status == "active" => {
657 let end = sub
658 .current_period_end
659 .map(|d| d.format("%B %-d, %Y").to_string());
660 (true, end)
661 }
662 _ => (false, None),
663 }
664 } else {
665 (false, None)
666 };
667
668 Ok(FanPlusTemplate {
669 csrf_token,
670 session_user: maybe_user,
671 is_subscribed,
672 period_end,
673 just_subscribed: query.subscribed.unwrap_or(false),
674 })
675 }
676