Skip to main content

max / makenotwork

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