//! Landing, authentication, and static public pages. use askama::Template as _; use crate::extractors::ValidatedQuery; use axum::{ Form, extract::{Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ AppCaches, Billing, Integrations, auth::{AuthUser, MaybeUserUnverified}, config::Config, constants, db, error::{AppError, Result}, helpers::get_csrf_token, routes::custom_domain, templates::{ CarouselFrame, CartTemplate, EconomicsTemplate, IndexTemplate, LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate, LibraryTemplate, }, types::{Collection, UserSubscription}, }; /// 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). /// Outcome of a no-JS notify submission, round-tripped through the redirect. /// /// The JS path renders its own status inline and never sets this; it exists so /// a visitor without JS gets told what happened instead of landing back on an /// apparently unchanged page. #[derive(Deserialize)] pub(super) struct IndexQuery { notify: Option, } #[tracing::instrument(skip_all, name = "landing::index")] #[allow(clippy::too_many_arguments)] pub(super) async fn index( State(db): State, State(caches): State, State(integrations): State, State(config): State, State(billing): State, Query(q): Query, 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( &db, &caches, &integrations, &config, &headers, "/", &session, maybe_user.as_ref(), ) .await { return Ok(response); } match maybe_user { Some(_) => Ok(Redirect::to("/library").into_response()), None => { let total_creators = db::waitlist::count_active_creators(&db).await? as u32; let total_items = db::items::count_public_listed(&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(&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 = config.creator_pricing.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 }; // Real captures of testnot.work, all three from one run of // scripts/capture-landing-carousel.mjs. Re-shoot with that script // rather than by hand: the three agree on viewport, scale, crop and // aspect by construction, and shooting one of them alone is what // makes a carousel look wrong. // // The alt text describes what is actually in each frame, not what // the page it came from contains. Frame 1's crop holds the shop and // its cover art; the prices sit below the fold, so it does not claim // them. let landing_carousel = vec![ CarouselFrame::new( "/static/images/shots/storefront.webp", "A creator's storefront on Makenotwork, titled and described, above a row of their work in cover art", ) .with_caption("Your storefront: sell anything digital"), CarouselFrame::new( "/static/images/shots/item.webp", "An item page showing its cover art, an $8 price, and a buy button, beside what the purchase includes", ) .with_caption("Every sale is yours. 0% platform fee"), CarouselFrame::new( "/static/images/shots/library.webp", "A buyer's library listing what they have bought, each row with its creator, type, purchase date and a button to open it", ) .with_caption("Buyers keep what they bought. One-click export"), ]; Ok(IndexTemplate { csrf_token: get_csrf_token(&session).await, host_url: config.host_url.clone(), total_creators, total_items: total_items as u32, founder_window_open, founder_slots_remaining, tier_prices: billing.tier_prices.clone(), landing_carousel, last_shipped, notify_ok: match q.notify.as_deref() { Some("ok") => Some(true), Some("invalid") => Some(false), _ => None, }, } .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(db): State, State(config): State, session: Session, AuthUser(user): AuthUser, ) -> Result { let purchases = db::transactions::get_user_purchases(&db, user.id).await?; let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?; let subscriptions: Vec = db_subs.iter().map(UserSubscription::from).collect(); let has_mt_memberships = config.integrations.mt_base_url.is_some(); // The shown panel is rendered here rather than fetched, which is what the // page's `{% include %}` did before the strip was described and is why this // conversion changes the request count by zero. `6b24f2df`. let shown = LibraryPurchasesTabTemplate { purchases, subscriptions, } .render() .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; let can_create_projects = user.can_create_projects; Ok(LibraryTemplate { csrf_token: get_csrf_token(&session).await, session_user: Some(user), tabs: crate::quasi::library_tabs::html(&shown, has_mt_memberships, can_create_projects), }) } /// 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(db): State, session: Session, AuthUser(user): AuthUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { use crate::templates::CartSellerGroup; use std::collections::BTreeMap; let cart_items = db::cart::get_cart_items(&db, user.id).await?; // The buyer's own currency is only a fact once they have connected Stripe; // otherwise `settlement_currency` is just the column default and says // nothing about the card they will pay with. let buyer = db::users::get_user_by_id(&db, user.id).await?; let buyer_currency = buyer .as_ref() .filter(|u| u.stripe_account_id.is_some()) .map(|u| u.settlement_currency); let buyer_conversion = buyer .as_ref() .map(|u| u.conversion_preference) .unwrap_or_default(); // Group by seller let mut groups: BTreeMap> = BTreeMap::new(); for item in &cart_items { 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: i64 = items .iter() .map(|i| i64::from(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() .is_some_and(|i| i.seller_stripe_account_id.is_some() && i.seller_charges_enabled); // One group is one seller, so the first item's currency is the // group's. An empty group cannot reach here (groups are built by // grouping items), but default rather than panic if that changes. let currency = items .first() .map(|i| i.settlement_currency) .unwrap_or_default(); CartSellerGroup { offer_conversion_choice: crate::templates::cart_conversion_applies( buyer_currency, currency, ), conversion: buyer_conversion, seller_username, seller_id: seller_id_str, stripe_ready, items, subtotal_cents, item_count, savings_cents, currency, } }) .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(&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(); let offer_conversion_choice = seller_groups.iter().any(|g| g.offer_conversion_choice); 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(), offer_conversion_choice, conversion: buyer_conversion, }) } /// HTMX partial: library purchases tab (includes subscriptions). #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")] pub(super) async fn library_tab_purchases( State(db): State, AuthUser(user): AuthUser, ) -> Result { let purchases = db::transactions::get_user_purchases(&db, user.id).await?; let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?; let subscriptions: Vec = db_subs.iter().map(UserSubscription::from).collect(); Ok(LibraryPurchasesTabTemplate { purchases, subscriptions, }) } /// Query parameters for the feed, on the one surface that still parses them /// through an extractor. The page is described and reads `?page=` off its own /// carried values. #[derive(Debug, Deserialize)] pub(super) struct FeedQuery { pub page: Option, } /// HTMX partial: library feed tab. #[tracing::instrument(skip_all, name = "landing::library_tab_feed")] pub(super) async fn library_tab_feed( State(db): State, AuthUser(user): AuthUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { // The panel is described (`crate::quasi::feeds`), so this answers markup // rather than a template, and the arithmetic behind it is `feeds::load`: // the clamp, the i64 widening and the saturating labels are three overflow // fixes that used to be copied here verbatim. let loaded = crate::quasi::feeds::load(&db, user.id, query.page).await?; Ok(axum::response::Html(crate::quasi::feeds::library_fragment( &loaded.page(), ))) } /// HTMX partial: library collections tab (includes wishlists). #[tracing::instrument(skip_all, name = "landing::library_tab_collections")] pub(super) async fn library_tab_collections( State(db): State, AuthUser(user): AuthUser, ) -> Result { let db_collections = db::collections::get_collections_by_user(&db, user.id).await?; let collections: Vec = db_collections.iter().map(Collection::from).collect(); let wishlists = db::wishlists::get_wishlist(&db, user.id).await?; Ok(LibraryCollectionsTabTemplate { collections, username: user.username.to_string(), wishlists, }) } /// 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(config): State, session: Session, ValidatedQuery(query): ValidatedQuery, ) -> impl IntoResponse { let sso_enabled = 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, }; let csrf_token = get_csrf_token(&session).await; axum::response::Html(crate::quasi::auth_pages::document( csrf_token.as_deref(), &crate::quasi::auth_pages::login( "", query.sso_error.as_deref(), notice.as_deref(), sso_enabled, ), )) } #[derive(Deserialize)] pub(super) struct NotifyForm { email: String, } /// POST /notify: the landing page's "notify me" capture. /// /// The form posts here directly, so it works with JS off. The page script /// intercepts the submit and posts the same form encoding to the same route, /// which makes it a genuine enhancement (async, inline status, no reload) /// rather than the only path. Before this the form had no action and no /// method, so a browser without JS submitted a GET to `/` and the address was /// dropped without a word to anyone. /// /// Storage is `email_signups`, which already existed for exactly this with a /// `source` column, duplicate collapsing, and an admin view at /// /admin/signups, so nobody has to be told where the addresses went. /// /// A rejected address redirects rather than erroring: this is the last thing /// on the landing page, and a 422 on a marketing form is a worse outcome than /// a sentence saying the address looked wrong. #[tracing::instrument(skip_all, name = "landing::notify")] pub(super) async fn notify( State(db): State, Form(form): Form, ) -> Result { let Ok(email) = db::Email::new(&form.email) else { return Ok(Redirect::to("/?notify=invalid#notify-form")); }; db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?; Ok(Redirect::to("/?notify=ok#notify-form")) } /// 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(db): State, State(payments): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { let paying_creators = crate::db::creator_tiers::count_active_paying(&db).await?; let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&db).await?; Ok(EconomicsTemplate { csrf_token: get_csrf_token(&session).await, session_user: maybe_user, runway_config: payments.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 | Makenotwork

Payment complete

You can close this tab and return to the app.

"#, ) }