Skip to main content

max / makenotwork

23.1 KB · 648 lines History Blame Raw
1 //! Landing, authentication, and static public pages.
2
3 use axum::{
4 extract::{Query, State},
5 http::HeaderMap,
6 response::{IntoResponse, Redirect, Response},
7 };
8 use serde::Deserialize;
9 use sqlx::PgPool;
10 use tower_sessions::Session;
11
12 use crate::{
13 AppCaches, Billing, Integrations,
14 auth::{AuthUser, MaybeUserUnverified},
15 config::Config,
16 constants, db,
17 error::{AppError, Result},
18 helpers::{self, get_csrf_token},
19 routes::custom_domain,
20 templates::{
21 CarouselFrame, CartTemplate, EconomicsTemplate, FanPlusTemplate, ForumMembership,
22 IndexTemplate, LandingVelocity, LibraryCollectionsTabTemplate,
23 LibraryCommunitiesTabTemplate, LibraryContactsTabTemplate, LibraryPurchasesTabTemplate,
24 LibraryTemplate, LoginTemplate, PolicyTemplate, PricingComparisonPartial, PricingTemplate,
25 TeamTemplate, UseCasesTemplate,
26 },
27 types::{Collection, ContactRow, DiscoverItem, UserSubscription},
28 };
29
30 /// Render the landing page, or redirect authenticated users to the library.
31 ///
32 /// If the Host header belongs to a verified custom domain, renders that user's
33 /// profile instead (the fallback handler only catches paths that don't match
34 /// any named route, so `/` needs to be handled here).
35 #[tracing::instrument(skip_all, name = "landing::index")]
36 #[allow(clippy::too_many_arguments)]
37 pub(super) async fn index(
38 State(db): State<PgPool>,
39 State(caches): State<AppCaches>,
40 State(integrations): State<Integrations>,
41 State(config): State<Config>,
42 State(billing): State<Billing>,
43 headers: HeaderMap,
44 session: Session,
45 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
46 ) -> Result<Response> {
47 // Check for custom domain, delegate to the custom domain handler
48 if let Some(response) = custom_domain::try_handle(
49 &db,
50 &caches,
51 &integrations,
52 &config,
53 &headers,
54 "/",
55 &session,
56 maybe_user.as_ref(),
57 )
58 .await
59 {
60 return Ok(response);
61 }
62
63 match maybe_user {
64 Some(_) => Ok(Redirect::to("/library").into_response()),
65 None => {
66 let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
67 let total_items = db::items::count_public_listed(&db).await?;
68
69 // "Last shipped" velocity line: most recent published, landing-
70 // flagged post on the changelog project. Read once per render; the
71 // line is suppressed entirely when nothing qualifies (no
72 // placeholder), matching the runway disclosure's no-fabrication rule.
73 let last_shipped =
74 db::blog_posts::get_landing_changelog_post(&db, constants::CHANGELOG_PROJECT_SLUG)
75 .await?
76 .and_then(|post| {
77 post.published_at.map(|published_at| LandingVelocity {
78 title: post.title,
79 date: published_at.format("%b %d, %Y").to_string(),
80 href: format!("/changelog/{}", post.slug),
81 })
82 });
83
84 // Surface remaining founder slots only when close enough to feel
85 // scarce. 200 is "last chunk", enough warning to convert, not so
86 // early that the number stays prominent for months.
87 let founder_window_open = config.creator_pricing.founder_window_open;
88 const FOUNDER_CAP: u32 = 1_000;
89 const URGENCY_THRESHOLD: u32 = 200;
90 let founder_slots_remaining = if founder_window_open
91 && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD)
92 {
93 Some(FOUNDER_CAP.saturating_sub(total_creators))
94 } else {
95 None
96 };
97
98 // Placeholder carousel frames. Swap the images for real captures and
99 // tighten the alt text when the screenshots exist (launch plan ยง S).
100 // The alt text below is written as real descriptions, not "image of
101 // a screenshot", to model the bar CarouselFrame::new nudges toward.
102 let landing_carousel = vec![
103 CarouselFrame::new(
104 "/static/images/shots/placeholder-storefront.svg",
105 "A creator's storefront on Makenotwork showing their listed items with prices and cover art",
106 )
107 .with_caption("Your storefront: sell anything digital"),
108 CarouselFrame::new(
109 "/static/images/shots/placeholder-item.svg",
110 "An item page with its price, buy button, and download details",
111 )
112 .with_caption("Every sale is yours. 0% platform fee"),
113 CarouselFrame::new(
114 "/static/images/shots/placeholder-library.svg",
115 "A buyer's library listing the files they have purchased, ready to download",
116 )
117 .with_caption("Buyers keep what they bought. One-click export"),
118 ];
119
120 Ok(IndexTemplate {
121 csrf_token: get_csrf_token(&session).await,
122 host_url: config.host_url.clone(),
123 total_creators,
124 total_items: total_items as u32,
125 founder_window_open,
126 founder_slots_remaining,
127 tier_prices: billing.tier_prices.clone(),
128 landing_carousel,
129 last_shipped,
130 }
131 .into_response())
132 }
133 }
134 }
135
136 /// Render the authenticated user's library with inline purchases tab.
137 #[tracing::instrument(skip_all, name = "landing::library")]
138 pub(super) async fn library(
139 State(db): State<PgPool>,
140 State(config): State<Config>,
141 session: Session,
142 AuthUser(user): AuthUser,
143 ) -> Result<impl IntoResponse> {
144 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
145 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
146 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
147 let has_mt_memberships = config.integrations.mt_base_url.is_some();
148 Ok(LibraryTemplate {
149 csrf_token: get_csrf_token(&session).await,
150 session_user: Some(user),
151 purchases,
152 subscriptions,
153 has_mt_memberships,
154 })
155 }
156
157 /// Query parameters for the cart page.
158 #[derive(Deserialize)]
159 pub(super) struct CartQuery {
160 pub checkout: Option<String>,
161 }
162
163 /// Render the shopping cart page with items grouped by seller.
164 #[tracing::instrument(skip_all, name = "landing::cart_page")]
165 pub(super) async fn cart_page(
166 State(db): State<PgPool>,
167 session: Session,
168 AuthUser(user): AuthUser,
169 Query(query): Query<CartQuery>,
170 ) -> Result<impl IntoResponse> {
171 use crate::templates::CartSellerGroup;
172 use std::collections::BTreeMap;
173
174 let cart_items = db::cart::get_cart_items(&db, user.id).await?;
175
176 // Group by seller
177 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
178 for item in &cart_items {
179 groups
180 .entry(item.seller_id.to_string())
181 .or_default()
182 .push(item.clone());
183 }
184
185 let seller_groups: Vec<CartSellerGroup> = groups
186 .into_iter()
187 .map(|(seller_id_str, items)| {
188 let subtotal_cents: i64 = items
189 .iter()
190 .map(|i| i64::from(i.effective_price_cents()))
191 .sum();
192 let item_count = items.len();
193 // Savings: buying N items in one session saves (N-1) * $0.30
194 let savings_cents = if item_count > 1 {
195 (item_count as i32 - 1) * 30
196 } else {
197 0
198 };
199 let seller_username = items
200 .first()
201 .map(|i| i.creator_username.clone())
202 .unwrap_or_default();
203 let stripe_ready = items
204 .first()
205 .is_some_and(|i| i.seller_stripe_account_id.is_some() && i.seller_charges_enabled);
206
207 CartSellerGroup {
208 seller_username,
209 seller_id: seller_id_str,
210 stripe_ready,
211 items,
212 subtotal_cents,
213 item_count,
214 savings_cents,
215 }
216 })
217 .collect();
218
219 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
220
221 // Wishlist suggestions: items in wishlist but not in cart
222 let wishlist = db::wishlists::get_wishlist(&db, user.id).await?;
223 let cart_item_ids: std::collections::HashSet<_> =
224 cart_items.iter().map(|i| i.item_id).collect();
225 let wishlist_suggestions: Vec<_> = wishlist
226 .into_iter()
227 .filter(|w| !cart_item_ids.contains(&w.item_id))
228 .take(10)
229 .collect();
230
231 Ok(CartTemplate {
232 csrf_token: get_csrf_token(&session).await,
233 session_user: Some(user),
234 seller_groups,
235 wishlist_suggestions,
236 total_items,
237 checkout_status: query.checkout.unwrap_or_default(),
238 })
239 }
240
241 /// HTMX partial: library purchases tab (includes subscriptions).
242 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
243 pub(super) async fn library_tab_purchases(
244 State(db): State<PgPool>,
245 AuthUser(user): AuthUser,
246 ) -> Result<impl IntoResponse> {
247 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
248 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
249 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
250 Ok(LibraryPurchasesTabTemplate {
251 purchases,
252 subscriptions,
253 })
254 }
255
256 /// HTMX partial: library feed tab.
257 #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
258 pub(super) async fn library_tab_feed(
259 State(db): State<PgPool>,
260 AuthUser(user): AuthUser,
261 Query(query): Query<super::feed::FeedQuery>,
262 ) -> Result<impl IntoResponse> {
263 use crate::templates::LibraryFeedTabTemplate;
264
265 // Clamp the upper bound like every sibling paginator (feed/discover/
266 // admin/issues): an unbounded `?page=` would issue a giant SQL OFFSET
267 // deep-scan. Widen to i64 BEFORE multiplying to avoid u32 overflow.
268 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
269 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
270
271 let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32;
272 let total_pages =
273 (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
274
275 let db_items = db::follows::get_followed_feed_items(
276 &db,
277 user.id,
278 constants::FEED_PAGE_SIZE as i64,
279 offset,
280 )
281 .await?;
282
283 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
284
285 // Compute the "showing Xโ€“Y" labels in i64 (saturating) to avoid the u32
286 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`.
287 let showing_start = if total_items == 0 {
288 0
289 } else {
290 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
291 };
292 let showing_end = offset
293 .saturating_add(constants::FEED_PAGE_SIZE as i64)
294 .min(total_items as i64)
295 .clamp(0, u32::MAX as i64) as u32;
296 let pagination_range = super::pagination::build_pagination_range(page, total_pages);
297
298 Ok(LibraryFeedTabTemplate {
299 items,
300 total_items,
301 current_page: page,
302 total_pages,
303 pagination_range,
304 showing_start,
305 showing_end,
306 })
307 }
308
309 /// HTMX partial: library collections tab (includes wishlists).
310 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
311 pub(super) async fn library_tab_collections(
312 State(db): State<PgPool>,
313 AuthUser(user): AuthUser,
314 ) -> Result<impl IntoResponse> {
315 let db_collections = db::collections::get_collections_by_user(&db, user.id).await?;
316 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
317 let wishlists = db::wishlists::get_wishlist(&db, user.id).await?;
318 Ok(LibraryCollectionsTabTemplate {
319 collections,
320 username: user.username.to_string(),
321 wishlists,
322 })
323 }
324
325 /// HTMX partial: library contacts tab.
326 #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")]
327 pub(super) async fn library_tab_contacts(
328 State(db): State<PgPool>,
329 AuthUser(user): AuthUser,
330 ) -> Result<impl IntoResponse> {
331 let shared_creators = db::transactions::get_shared_creators(&db, user.id).await?;
332
333 // Fetch seller contacts (buyers who shared their email) if user is a creator
334 let db_user = db::users::get_user_by_id(&db, user.id)
335 .await?
336 .ok_or(AppError::NotFound)?;
337 let db_contacts = if db_user.can_create_projects {
338 db::transactions::get_seller_contacts(&db, user.id).await?
339 } else {
340 vec![]
341 };
342 let total_buyer_contacts = db_contacts.len();
343 let buyer_contacts: Vec<ContactRow> = db_contacts
344 .into_iter()
345 .map(|c| ContactRow {
346 username: c.username,
347 email: c.email,
348 total_purchases: c.total_purchases,
349 total_spent: helpers::format_revenue(c.total_spent_cents),
350 last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
351 })
352 .collect();
353
354 Ok(LibraryContactsTabTemplate {
355 shared_creators,
356 buyer_contacts,
357 total_buyer_contacts,
358 })
359 }
360
361 /// HTMX partial: library communities tab (Multithreaded forum memberships).
362 #[tracing::instrument(skip_all, name = "landing::library_tab_communities")]
363 pub(super) async fn library_tab_communities(
364 State(config): State<Config>,
365 AuthUser(user): AuthUser,
366 ) -> Result<axum::response::Response> {
367 let Some(mt_base_url) = config.integrations.mt_base_url.as_ref() else {
368 return Ok(LibraryCommunitiesTabTemplate {
369 memberships: vec![],
370 mt_base_url: String::new(),
371 }
372 .into_response());
373 };
374
375 let url = format!("{}/api/user/{}/summary", mt_base_url, user.id);
376
377 let resp = crate::helpers::HTTP_CLIENT
378 .get(&url)
379 .timeout(std::time::Duration::from_secs(5))
380 .send()
381 .await
382 .map_err(|e| {
383 tracing::warn!(error = ?e, "failed to fetch MT user summary");
384 AppError::Internal(anyhow::anyhow!("MT API unavailable"))
385 })?;
386
387 if !resp.status().is_success() {
388 return Ok(LibraryCommunitiesTabTemplate {
389 memberships: vec![],
390 mt_base_url: mt_base_url.clone(),
391 }
392 .into_response());
393 }
394
395 let json: serde_json::Value = resp.json().await.map_err(|e| {
396 tracing::warn!(error = ?e, "failed to parse MT summary response");
397 AppError::Internal(anyhow::anyhow!("MT API response invalid"))
398 })?;
399
400 let memberships = json["memberships"]
401 .as_array()
402 .map(|arr| {
403 arr.iter()
404 .filter_map(|m| {
405 let community_slug = m["community_slug"].as_str()?;
406 Some(ForumMembership {
407 community_name: m["community_name"].as_str()?.to_string(),
408 profile_url: format!(
409 "{}/p/{}/u/{}",
410 mt_base_url, community_slug, user.username
411 ),
412 role: m["role"].as_str()?.to_string(),
413 joined: m["joined_at"]
414 .as_str()
415 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
416 .map(|dt| dt.format("%b %d, %Y").to_string())
417 .unwrap_or_default(),
418 post_count: m["post_count"].as_i64().unwrap_or(0),
419 })
420 })
421 .collect()
422 })
423 .unwrap_or_default();
424
425 Ok(LibraryCommunitiesTabTemplate {
426 memberships,
427 mt_base_url: mt_base_url.clone(),
428 }
429 .into_response())
430 }
431
432 /// Query params for the login page.
433 #[derive(Deserialize)]
434 pub(crate) struct LoginQuery {
435 /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
436 /// the visitor landed on login instead of the page they requested.
437 pub gate: Option<String>,
438 /// Set by the SSO callback when delegated login fails; shown as an error.
439 pub sso_error: Option<String>,
440 }
441
442 /// Render the login page.
443 #[tracing::instrument(skip_all, name = "landing::login_page")]
444 pub(crate) async fn login_page(
445 State(config): State<Config>,
446 session: Session,
447 Query(query): Query<LoginQuery>,
448 ) -> impl IntoResponse {
449 let sso_enabled = config.sso.is_some();
450 let notice = match query.gate.as_deref() {
451 Some("fan_plus_or_creator") => Some(
452 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
453 .to_string(),
454 ),
455 _ => None,
456 };
457 LoginTemplate {
458 csrf_token: get_csrf_token(&session).await,
459 prefill_login: String::new(),
460 error: query.sso_error,
461 notice,
462 sso_enabled,
463 }
464 }
465
466 /// Render the interactive pricing calculator page.
467 #[tracing::instrument(skip_all, name = "landing::pricing_page")]
468 pub(super) async fn pricing_page(
469 State(payments): State<Billing>,
470 session: Session,
471 ) -> impl IntoResponse {
472 let comparison = payments.pricing_comparison.compute(
473 PRICING_DEFAULT_REVENUE,
474 payments.tier_prices.basic_std as f64,
475 );
476 PricingTemplate {
477 csrf_token: get_csrf_token(&session).await,
478 tier_prices: payments.tier_prices.clone(),
479 comparison,
480 }
481 }
482
483 /// Default monthly revenue the calculator opens on, matches the `value` on the
484 /// revenue input in `pricing.html`.
485 const PRICING_DEFAULT_REVENUE: f64 = 1000.0;
486
487 /// Query for the pricing-comparison HTMX recompute. Both fields arrive as
488 /// strings (the revenue input can be cleared to empty) and are parsed
489 /// leniently so a blank or garbage value recomputes at zero rather than 400ing.
490 #[derive(Deserialize)]
491 pub(super) struct PricingCompareQuery {
492 revenue: Option<String>,
493 tier: Option<String>,
494 }
495
496 /// Recompute the pricing comparison server-side and return the table partial.
497 /// Pure computation, no auth, no state change, a GET so no CSRF is needed.
498 #[tracing::instrument(skip_all, name = "landing::pricing_compare")]
499 pub(super) async fn pricing_compare(
500 State(payments): State<Billing>,
501 Query(q): Query<PricingCompareQuery>,
502 ) -> impl IntoResponse {
503 let parse = |s: Option<String>| {
504 s.and_then(|v| v.trim().parse::<f64>().ok())
505 .filter(|v| v.is_finite())
506 };
507 let revenue = parse(q.revenue).unwrap_or(0.0).clamp(0.0, 999_999.0);
508 let tier = parse(q.tier)
509 .filter(|v| *v >= 0.0)
510 .unwrap_or(payments.tier_prices.basic_std as f64);
511 PricingComparisonPartial {
512 comparison: payments.pricing_comparison.compute(revenue, tier),
513 }
514 }
515
516 /// Render the platform-economics + runway disclosure page.
517 ///
518 /// Served top-level at `/economics` alongside the other landing pages
519 /// (the retired markdown source used to live at `/docs/economics`, which
520 /// now 301s here). Renders as Askama (not docengine markdown) so it can
521 /// carry live figures from the database. The two count queries are cheap
522 /// (each is a single `SELECT COUNT(*)` against an indexed status column);
523 /// no caching needed at current load.
524 #[tracing::instrument(skip_all, name = "landing::economics_page")]
525 pub(super) async fn economics_page(
526 State(db): State<PgPool>,
527 State(payments): State<Billing>,
528 session: Session,
529 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
530 ) -> Result<impl IntoResponse> {
531 let paying_creators = crate::db::creator_tiers::count_active_paying(&db).await?;
532 let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&db).await?;
533 Ok(EconomicsTemplate {
534 csrf_token: get_csrf_token(&session).await,
535 session_user: maybe_user,
536 runway_config: payments.runway_config.clone(),
537 paying_creators,
538 trialing_or_grace,
539 })
540 }
541
542 /// Lightweight checkout success page for app-initiated Stripe flows.
543 /// No auth required; the app polls for subscription status independently.
544 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
545 pub(super) async fn checkout_complete() -> impl IntoResponse {
546 axum::response::Html(
547 r#"<!DOCTYPE html>
548 <html lang="en">
549 <head>
550 <meta charset="UTF-8">
551 <meta name="viewport" content="width=device-width, initial-scale=1.0">
552 <title>Payment Complete | Makenot.work</title>
553 <link rel="stylesheet" href="/static/style.css">
554 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
555 </head>
556 <body>
557 <main id="main-content">
558 <div class="error-page">
559 <div class="error-container">
560 <h1 class="error-title">Payment complete</h1>
561 <p class="error-message">You can close this tab and return to the app.</p>
562 </div>
563 </div>
564 </main>
565 </body>
566 </html>"#,
567 )
568 }
569
570 /// Render the use cases page.
571 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
572 pub(super) async fn use_cases_page(
573 State(payments): State<Billing>,
574 session: Session,
575 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
576 ) -> impl IntoResponse {
577 UseCasesTemplate {
578 csrf_token: get_csrf_token(&session).await,
579 session_user: maybe_user,
580 tier_prices: payments.tier_prices.clone(),
581 }
582 }
583
584 /// Render the team page.
585 #[tracing::instrument(skip_all, name = "landing::team_page")]
586 pub(super) async fn team_page(
587 session: Session,
588 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
589 ) -> impl IntoResponse {
590 TeamTemplate {
591 csrf_token: get_csrf_token(&session).await,
592 session_user: maybe_user,
593 }
594 }
595
596 /// Render the content policy page.
597 #[tracing::instrument(skip_all, name = "landing::policy_page")]
598 pub(super) async fn policy_page(
599 session: Session,
600 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
601 ) -> impl IntoResponse {
602 let csrf_token = get_csrf_token(&session).await;
603 PolicyTemplate {
604 csrf_token,
605 session_user: maybe_user,
606 }
607 }
608
609 /// Query params for the Fan+ page.
610 #[derive(Debug, Deserialize)]
611 pub(super) struct FanPlusQuery {
612 pub subscribed: Option<bool>,
613 }
614
615 /// Render the Fan+ subscription page.
616 #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
617 pub(super) async fn fan_plus_page(
618 State(db): State<PgPool>,
619 session: Session,
620 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
621 Query(query): Query<FanPlusQuery>,
622 ) -> Result<impl IntoResponse> {
623 let csrf_token = get_csrf_token(&session).await;
624
625 let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
626 let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?;
627 match fan_sub {
628 Some(sub) if sub.status == "active" => {
629 let end = sub
630 .current_period_end
631 .map(|d| d.format("%B %-d, %Y").to_string());
632 (true, end)
633 }
634 _ => (false, None),
635 }
636 } else {
637 (false, None)
638 };
639
640 Ok(FanPlusTemplate {
641 csrf_token,
642 session_user: maybe_user,
643 is_subscribed,
644 period_end,
645 just_subscribed: query.subscribed.unwrap_or(false),
646 })
647 }
648