//! Landing, authentication, and static public pages. use axum::{ extract::{Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::{AuthUser, MaybeUserUnverified}, constants, db, error::{AppError, Result}, helpers::{self, get_csrf_token}, routes::custom_domain, templates::*, types::*, AppState, }; /// Render the landing page, or redirect authenticated users to the library. /// /// If the Host header belongs to a verified custom domain, renders that user's /// profile instead (the fallback handler only catches paths that don't match /// any named route, so `/` needs to be handled here). #[tracing::instrument(skip_all, name = "landing::index")] pub(super) async fn index( State(state): State, headers: HeaderMap, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { // Check for custom domain — delegate to the custom domain handler if let Some(response) = custom_domain::try_handle(&state, &headers, "/", &session, &maybe_user).await { return Ok(response); } match maybe_user { Some(_) => Ok(Redirect::to("/library").into_response()), None => { let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32; let total_items = db::items::count_public_listed(&state.db).await?; // "Last shipped" velocity line: most recent published, landing- // flagged post on the changelog project. Read once per render; the // line is suppressed entirely when nothing qualifies (no // placeholder), matching the runway disclosure's no-fabrication rule. let last_shipped = db::blog_posts::get_landing_changelog_post( &state.db, constants::CHANGELOG_PROJECT_SLUG, ) .await? .and_then(|post| { post.published_at.map(|published_at| LandingVelocity { title: post.title, date: published_at.format("%b %d, %Y").to_string(), href: format!("/changelog/{}", post.slug), }) }); // Surface remaining founder slots only when close enough to feel // scarce. 200 is "last chunk" — enough warning to convert, not so // early that the number stays prominent for months. let founder_window_open = state.config.creator_founder_window_open; const FOUNDER_CAP: u32 = 1_000; const URGENCY_THRESHOLD: u32 = 200; let founder_slots_remaining = if founder_window_open && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD) { Some(FOUNDER_CAP.saturating_sub(total_creators)) } else { None }; // Placeholder carousel frames. Swap the images for real captures and // tighten the alt text when the screenshots exist (launch plan § S). // The alt text below is written as real descriptions, not "image of // a screenshot", to model the bar CarouselFrame::new nudges toward. let landing_carousel = vec![ CarouselFrame::new( "/static/images/shots/placeholder-storefront.svg", "A creator's storefront on Makenotwork showing their listed items with prices and cover art", ) .with_caption("Your storefront — sell anything digital"), CarouselFrame::new( "/static/images/shots/placeholder-item.svg", "An item page with its price, buy button, and download details", ) .with_caption("Every sale is yours — 0% platform fee"), CarouselFrame::new( "/static/images/shots/placeholder-library.svg", "A buyer's library listing the files they have purchased, ready to download", ) .with_caption("Buyers keep what they bought — one-click export"), ]; Ok(IndexTemplate { csrf_token: get_csrf_token(&session).await, host_url: state.config.host_url.clone(), total_creators, total_items: total_items as u32, founder_window_open, founder_slots_remaining, tier_prices: state.tier_prices.clone(), landing_carousel, last_shipped, }.into_response()) } } } /// Render the authenticated user's library with inline purchases tab. #[tracing::instrument(skip_all, name = "landing::library")] pub(super) async fn library( State(state): State, session: Session, AuthUser(user): AuthUser, ) -> Result { let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; let subscriptions: Vec = db_subs.iter().map(UserSubscription::from).collect(); let has_mt_memberships = state.config.mt_base_url.is_some(); Ok(LibraryTemplate { csrf_token: get_csrf_token(&session).await, session_user: Some(user), purchases, subscriptions, has_mt_memberships, }) } /// Query parameters for the cart page. #[derive(Deserialize)] pub(super) struct CartQuery { pub checkout: Option, } /// Render the shopping cart page with items grouped by seller. #[tracing::instrument(skip_all, name = "landing::cart_page")] pub(super) async fn cart_page( State(state): State, session: Session, AuthUser(user): AuthUser, Query(query): Query, ) -> Result { use std::collections::BTreeMap; use crate::templates::CartSellerGroup; let cart_items = db::cart::get_cart_items(&state.db, user.id).await?; // Group by seller let mut groups: BTreeMap> = BTreeMap::new(); for item in cart_items.iter() { groups .entry(item.seller_id.to_string()) .or_default() .push(item.clone()); } let seller_groups: Vec = groups .into_iter() .map(|(seller_id_str, items)| { let subtotal_cents: i32 = items.iter().map(|i| i.effective_price_cents()).sum(); let item_count = items.len(); // Savings: buying N items in one session saves (N-1) * $0.30 let savings_cents = if item_count > 1 { (item_count as i32 - 1) * 30 } else { 0 }; let seller_username = items.first().map(|i| i.creator_username.clone()).unwrap_or_default(); let stripe_ready = items.first().map(|i| { i.seller_stripe_account_id.is_some() && i.seller_charges_enabled }).unwrap_or(false); CartSellerGroup { seller_username, seller_id: seller_id_str, stripe_ready, items, subtotal_cents, item_count, savings_cents, } }) .collect(); let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum(); // Wishlist suggestions: items in wishlist but not in cart let wishlist = db::wishlists::get_wishlist(&state.db, user.id).await?; let cart_item_ids: std::collections::HashSet<_> = cart_items.iter().map(|i| i.item_id).collect(); let wishlist_suggestions: Vec<_> = wishlist .into_iter() .filter(|w| !cart_item_ids.contains(&w.item_id)) .take(10) .collect(); Ok(CartTemplate { csrf_token: get_csrf_token(&session).await, session_user: Some(user), seller_groups, wishlist_suggestions, total_items, checkout_status: query.checkout.unwrap_or_default(), }) } /// HTMX partial: library purchases tab (includes subscriptions). #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")] pub(super) async fn library_tab_purchases( State(state): State, AuthUser(user): AuthUser, ) -> Result { let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; let subscriptions: Vec = db_subs.iter().map(UserSubscription::from).collect(); Ok(LibraryPurchasesTabTemplate { purchases, subscriptions }) } /// HTMX partial: library feed tab. #[tracing::instrument(skip_all, name = "landing::library_tab_feed")] pub(super) async fn library_tab_feed( State(state): State, AuthUser(user): AuthUser, Query(query): Query, ) -> Result { use crate::templates::LibraryFeedTabTemplate; let page = query.page.unwrap_or(1).max(1); // Widen to i64 BEFORE multiplying to avoid u32 overflow on a large `?page=`. let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32; let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1); let db_items = db::follows::get_followed_feed_items( &state.db, user.id, constants::FEED_PAGE_SIZE as i64, offset, ) .await?; let items: Vec = db_items.into_iter().map(DiscoverItem::from).collect(); // Compute the "showing X–Y" labels in i64 (saturating) to avoid the u32 // overflow `offset as u32 + FEED_PAGE_SIZE` would hit for a large `?page=`. let showing_start = if total_items == 0 { 0 } else { offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 }; let showing_end = offset .saturating_add(constants::FEED_PAGE_SIZE as i64) .min(total_items as i64) .clamp(0, u32::MAX as i64) as u32; let pagination_range = super::feed::build_pagination_range(page, total_pages); Ok(LibraryFeedTabTemplate { items, total_items, current_page: page, total_pages, pagination_range, showing_start, showing_end, }) } /// HTMX partial: library collections tab (includes wishlists). #[tracing::instrument(skip_all, name = "landing::library_tab_collections")] pub(super) async fn library_tab_collections( State(state): State, AuthUser(user): AuthUser, ) -> Result { let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?; let collections: Vec = db_collections.iter().map(Collection::from).collect(); let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?; Ok(LibraryCollectionsTabTemplate { collections, username: user.username.to_string(), wishlists, }) } /// HTMX partial: library contacts tab. #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")] pub(super) async fn library_tab_contacts( State(state): State, AuthUser(user): AuthUser, ) -> Result { let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?; // Fetch seller contacts (buyers who shared their email) if user is a creator let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; let db_contacts = if db_user.can_create_projects { db::transactions::get_seller_contacts(&state.db, user.id).await? } else { vec![] }; let total_buyer_contacts = db_contacts.len(); let buyer_contacts: Vec = db_contacts .into_iter() .map(|c| ContactRow { username: c.username, email: c.email, total_purchases: c.total_purchases, total_spent: helpers::format_revenue(c.total_spent_cents), last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(), }) .collect(); Ok(LibraryContactsTabTemplate { shared_creators, buyer_contacts, total_buyer_contacts }) } /// HTMX partial: library communities tab (Multithreaded forum memberships). #[tracing::instrument(skip_all, name = "landing::library_tab_communities")] pub(super) async fn library_tab_communities( State(state): State, AuthUser(user): AuthUser, ) -> Result { let mt_base_url = match state.config.mt_base_url.as_ref() { Some(url) => url, None => { return Ok(LibraryCommunitiesTabTemplate { memberships: vec![], mt_base_url: String::new(), } .into_response()) } }; let url = format!("{}/api/user/{}/summary", mt_base_url, user.id); let resp = reqwest::Client::new() .get(&url) .timeout(std::time::Duration::from_secs(5)) .send() .await .map_err(|e| { tracing::warn!(error = ?e, "failed to fetch MT user summary"); AppError::Internal(anyhow::anyhow!("MT API unavailable")) })?; if !resp.status().is_success() { return Ok(LibraryCommunitiesTabTemplate { memberships: vec![], mt_base_url: mt_base_url.clone(), } .into_response()); } let json: serde_json::Value = resp.json().await.map_err(|e| { tracing::warn!(error = ?e, "failed to parse MT summary response"); AppError::Internal(anyhow::anyhow!("MT API response invalid")) })?; let memberships = json["memberships"] .as_array() .map(|arr| { arr.iter() .filter_map(|m| { let community_slug = m["community_slug"].as_str()?; Some(ForumMembership { community_name: m["community_name"].as_str()?.to_string(), profile_url: format!( "{}/p/{}/u/{}", mt_base_url, community_slug, user.username ), role: m["role"].as_str()?.to_string(), joined: m["joined_at"] .as_str() .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) .map(|dt| dt.format("%b %d, %Y").to_string()) .unwrap_or_default(), post_count: m["post_count"].as_i64().unwrap_or(0), }) }) .collect() }) .unwrap_or_default(); Ok(LibraryCommunitiesTabTemplate { memberships, mt_base_url: mt_base_url.clone(), } .into_response()) } /// Query params for the login page. #[derive(Deserialize)] pub(crate) struct LoginQuery { /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why /// the visitor landed on login instead of the page they requested. pub gate: Option, /// Set by the SSO callback when delegated login fails; shown as an error. pub sso_error: Option, } /// Render the login page. #[tracing::instrument(skip_all, name = "landing::login_page")] pub(crate) async fn login_page( State(state): State, session: Session, Query(query): Query, ) -> impl IntoResponse { let sso_enabled = state.config.sso.is_some(); let notice = match query.gate.as_deref() { Some("fan_plus_or_creator") => Some( "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue." .to_string(), ), _ => None, }; LoginTemplate { csrf_token: get_csrf_token(&session).await, prefill_login: String::new(), error: query.sso_error, notice, sso_enabled, } } /// Render the interactive pricing calculator page. #[tracing::instrument(skip_all, name = "landing::pricing_page")] pub(super) async fn pricing_page( State(state): State, session: Session, ) -> impl IntoResponse { PricingTemplate { csrf_token: get_csrf_token(&session).await, tier_prices: state.tier_prices.clone(), cost_allocation: state.cost_allocation.clone(), } } /// Render the platform-economics + runway disclosure page. /// /// Served top-level at `/economics` alongside the other landing pages /// (the retired markdown source used to live at `/docs/economics`, which /// now 301s here). Renders as Askama (not docengine markdown) so it can /// carry live figures from the database. The two count queries are cheap /// (each is a single `SELECT COUNT(*)` against an indexed status column); /// no caching needed at current load. #[tracing::instrument(skip_all, name = "landing::economics_page")] pub(super) async fn economics_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { let paying_creators = crate::db::creator_tiers::count_active_paying(&state.db).await?; let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&state.db).await?; Ok(EconomicsTemplate { csrf_token: get_csrf_token(&session).await, session_user: maybe_user, runway_config: state.runway_config.clone(), paying_creators, trialing_or_grace, }) } /// Lightweight checkout success page for app-initiated Stripe flows. /// No auth required; the app polls for subscription status independently. #[tracing::instrument(skip_all, name = "landing::checkout_complete")] pub(super) async fn checkout_complete() -> impl IntoResponse { axum::response::Html( r#" Payment Complete | Makenot.work

Payment complete

You can close this tab and return to the app.

"#, ) } /// Render the use cases page. #[tracing::instrument(skip_all, name = "landing::use_cases_page")] pub(super) async fn use_cases_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> impl IntoResponse { UseCasesTemplate { csrf_token: get_csrf_token(&session).await, session_user: maybe_user, tier_prices: state.tier_prices.clone(), } } /// Render the team page. #[tracing::instrument(skip_all, name = "landing::team_page")] pub(super) async fn team_page( session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> impl IntoResponse { TeamTemplate { csrf_token: get_csrf_token(&session).await, session_user: maybe_user, } } /// Render the content policy page. #[tracing::instrument(skip_all, name = "landing::policy_page")] pub(super) async fn policy_page( session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> impl IntoResponse { let csrf_token = get_csrf_token(&session).await; PolicyTemplate { csrf_token, session_user: maybe_user, } } /// Query params for the Fan+ page. #[derive(Debug, Deserialize)] pub(super) struct FanPlusQuery { pub subscribed: Option, } /// Render the Fan+ subscription page. #[tracing::instrument(skip_all, name = "landing::fan_plus_page")] pub(super) async fn fan_plus_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Query(query): Query, ) -> Result { let csrf_token = get_csrf_token(&session).await; let (is_subscribed, period_end) = if let Some(ref user) = maybe_user { let fan_sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id).await?; match fan_sub { Some(sub) if sub.status == "active" => { let end = sub.current_period_end.map(|d| d.format("%B %-d, %Y").to_string()); (true, end) } _ => (false, None), } } else { (false, None) }; Ok(FanPlusTemplate { csrf_token, session_user: maybe_user, is_subscribed, period_end, just_subscribed: query.subscribed.unwrap_or(false), }) }