Skip to main content

max / makenotwork

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