Skip to main content

max / makenotwork

18.7 KB · 489 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::get_csrf_token,
23 routes::custom_domain,
24 templates::{
25 CarouselFrame, CartTemplate, EconomicsTemplate, IndexTemplate, LandingVelocity,
26 LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate, LibraryTemplate,
27 },
28 types::{Collection, UserSubscription},
29 };
30
31 /// Render the landing page, or redirect authenticated users to the library.
32 ///
33 /// If the Host header belongs to a verified custom domain, renders that user's
34 /// profile instead (the fallback handler only catches paths that don't match
35 /// any named route, so `/` needs to be handled here).
36 /// Outcome of a no-JS notify submission, round-tripped through the redirect.
37 ///
38 /// The JS path renders its own status inline and never sets this; it exists so
39 /// a visitor without JS gets told what happened instead of landing back on an
40 /// apparently unchanged page.
41 #[derive(Deserialize)]
42 pub(super) struct IndexQuery {
43 notify: Option<String>,
44 }
45
46 #[tracing::instrument(skip_all, name = "landing::index")]
47 #[allow(clippy::too_many_arguments)]
48 pub(super) async fn index(
49 State(db): State<PgPool>,
50 State(caches): State<AppCaches>,
51 State(integrations): State<Integrations>,
52 State(config): State<Config>,
53 State(billing): State<Billing>,
54 Query(q): Query<IndexQuery>,
55 headers: HeaderMap,
56 session: Session,
57 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
58 ) -> Result<Response> {
59 // Check for custom domain, delegate to the custom domain handler
60 if let Some(response) = custom_domain::try_handle(
61 &db,
62 &caches,
63 &integrations,
64 &config,
65 &headers,
66 "/",
67 &session,
68 maybe_user.as_ref(),
69 )
70 .await
71 {
72 return Ok(response);
73 }
74
75 match maybe_user {
76 Some(_) => Ok(Redirect::to("/library").into_response()),
77 None => {
78 let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
79 let total_items = db::items::count_public_listed(&db).await?;
80
81 // "Last shipped" velocity line: most recent published, landing-
82 // flagged post on the changelog project. Read once per render; the
83 // line is suppressed entirely when nothing qualifies (no
84 // placeholder), matching the runway disclosure's no-fabrication rule.
85 let last_shipped =
86 db::blog_posts::get_landing_changelog_post(&db, constants::CHANGELOG_PROJECT_SLUG)
87 .await?
88 .and_then(|post| {
89 post.published_at.map(|published_at| LandingVelocity {
90 title: post.title,
91 date: published_at.format("%b %d, %Y").to_string(),
92 href: format!("/changelog/{}", post.slug),
93 })
94 });
95
96 // Surface remaining founder slots only when close enough to feel
97 // scarce. 200 is "last chunk", enough warning to convert, not so
98 // early that the number stays prominent for months.
99 let founder_window_open = config.creator_pricing.founder_window_open;
100 const FOUNDER_CAP: u32 = 1_000;
101 const URGENCY_THRESHOLD: u32 = 200;
102 let founder_slots_remaining = if founder_window_open
103 && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD)
104 {
105 Some(FOUNDER_CAP.saturating_sub(total_creators))
106 } else {
107 None
108 };
109
110 // Real captures of testnot.work, all three from one run of
111 // scripts/capture-landing-carousel.mjs. Re-shoot with that script
112 // rather than by hand: the three agree on viewport, scale, crop and
113 // aspect by construction, and shooting one of them alone is what
114 // makes a carousel look wrong.
115 //
116 // The alt text describes what is actually in each frame, not what
117 // the page it came from contains. Frame 1's crop holds the shop and
118 // its cover art; the prices sit below the fold, so it does not claim
119 // them.
120 let landing_carousel = vec![
121 CarouselFrame::new(
122 "/static/images/shots/storefront.webp",
123 "A creator's storefront on Makenotwork, titled and described, above a row of their work in cover art",
124 )
125 .with_caption("Your storefront: sell anything digital"),
126 CarouselFrame::new(
127 "/static/images/shots/item.webp",
128 "An item page showing its cover art, an $8 price, and a buy button, beside what the purchase includes",
129 )
130 .with_caption("Every sale is yours. 0% platform fee"),
131 CarouselFrame::new(
132 "/static/images/shots/library.webp",
133 "A buyer's library listing what they have bought, each row with its creator, type, purchase date and a button to open it",
134 )
135 .with_caption("Buyers keep what they bought. One-click export"),
136 ];
137
138 Ok(IndexTemplate {
139 csrf_token: get_csrf_token(&session).await,
140 host_url: config.host_url.clone(),
141 total_creators,
142 total_items: total_items as u32,
143 founder_window_open,
144 founder_slots_remaining,
145 tier_prices: billing.tier_prices.clone(),
146 landing_carousel,
147 last_shipped,
148 notify_ok: match q.notify.as_deref() {
149 Some("ok") => Some(true),
150 Some("invalid") => Some(false),
151 _ => None,
152 },
153 }
154 .into_response())
155 }
156 }
157 }
158
159 /// Render the authenticated user's library with inline purchases tab.
160 #[tracing::instrument(skip_all, name = "landing::library")]
161 pub(super) async fn library(
162 State(db): State<PgPool>,
163 State(config): State<Config>,
164 session: Session,
165 AuthUser(user): AuthUser,
166 ) -> Result<impl IntoResponse> {
167 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
168 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
169 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
170 let has_mt_memberships = config.integrations.mt_base_url.is_some();
171
172 // The shown panel is rendered here rather than fetched, which is what the
173 // page's `{% include %}` did before the strip was described and is why this
174 // conversion changes the request count by zero. `6b24f2df`.
175 let shown = LibraryPurchasesTabTemplate {
176 purchases,
177 subscriptions,
178 }
179 .render()
180 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
181
182 let can_create_projects = user.can_create_projects;
183 Ok(LibraryTemplate {
184 csrf_token: get_csrf_token(&session).await,
185 session_user: Some(user),
186 tabs: crate::quasi::library_tabs::html(&shown, has_mt_memberships, can_create_projects),
187 })
188 }
189
190 /// Query parameters for the cart page.
191 #[derive(Deserialize)]
192 pub(super) struct CartQuery {
193 pub checkout: Option<String>,
194 }
195
196 /// Render the shopping cart page with items grouped by seller.
197 #[tracing::instrument(skip_all, name = "landing::cart_page")]
198 pub(super) async fn cart_page(
199 State(db): State<PgPool>,
200 session: Session,
201 AuthUser(user): AuthUser,
202 ValidatedQuery(query): ValidatedQuery<CartQuery>,
203 ) -> Result<impl IntoResponse> {
204 use crate::templates::CartSellerGroup;
205 use std::collections::BTreeMap;
206
207 let cart_items = db::cart::get_cart_items(&db, user.id).await?;
208
209 // The buyer's own currency is only a fact once they have connected Stripe;
210 // otherwise `settlement_currency` is just the column default and says
211 // nothing about the card they will pay with.
212 let buyer = db::users::get_user_by_id(&db, user.id).await?;
213 let buyer_currency = buyer
214 .as_ref()
215 .filter(|u| u.stripe_account_id.is_some())
216 .map(|u| u.settlement_currency);
217 let buyer_conversion = buyer
218 .as_ref()
219 .map(|u| u.conversion_preference)
220 .unwrap_or_default();
221
222 // Group by seller
223 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
224 for item in &cart_items {
225 groups
226 .entry(item.seller_id.to_string())
227 .or_default()
228 .push(item.clone());
229 }
230
231 let seller_groups: Vec<CartSellerGroup> = groups
232 .into_iter()
233 .map(|(seller_id_str, items)| {
234 let subtotal_cents: i64 = items
235 .iter()
236 .map(|i| i64::from(i.effective_price_cents()))
237 .sum();
238 let item_count = items.len();
239 // Savings: buying N items in one session saves (N-1) * $0.30
240 let savings_cents = if item_count > 1 {
241 (item_count as i32 - 1) * 30
242 } else {
243 0
244 };
245 let seller_username = items
246 .first()
247 .map(|i| i.creator_username.clone())
248 .unwrap_or_default();
249 let stripe_ready = items
250 .first()
251 .is_some_and(|i| i.seller_stripe_account_id.is_some() && i.seller_charges_enabled);
252
253 // One group is one seller, so the first item's currency is the
254 // group's. An empty group cannot reach here (groups are built by
255 // grouping items), but default rather than panic if that changes.
256 let currency = items
257 .first()
258 .map(|i| i.settlement_currency)
259 .unwrap_or_default();
260
261 CartSellerGroup {
262 offer_conversion_choice: crate::templates::cart_conversion_applies(
263 buyer_currency,
264 currency,
265 ),
266 conversion: buyer_conversion,
267 seller_username,
268 seller_id: seller_id_str,
269 stripe_ready,
270 items,
271 subtotal_cents,
272 item_count,
273 savings_cents,
274 currency,
275 }
276 })
277 .collect();
278
279 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
280
281 // Wishlist suggestions: items in wishlist but not in cart
282 let wishlist = db::wishlists::get_wishlist(&db, user.id).await?;
283 let cart_item_ids: std::collections::HashSet<_> =
284 cart_items.iter().map(|i| i.item_id).collect();
285 let wishlist_suggestions: Vec<_> = wishlist
286 .into_iter()
287 .filter(|w| !cart_item_ids.contains(&w.item_id))
288 .take(10)
289 .collect();
290
291 let offer_conversion_choice = seller_groups.iter().any(|g| g.offer_conversion_choice);
292
293 Ok(CartTemplate {
294 csrf_token: get_csrf_token(&session).await,
295 session_user: Some(user),
296 seller_groups,
297 wishlist_suggestions,
298 total_items,
299 checkout_status: query.checkout.unwrap_or_default(),
300 offer_conversion_choice,
301 conversion: buyer_conversion,
302 })
303 }
304
305 /// HTMX partial: library purchases tab (includes subscriptions).
306 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
307 pub(super) async fn library_tab_purchases(
308 State(db): State<PgPool>,
309 AuthUser(user): AuthUser,
310 ) -> Result<impl IntoResponse> {
311 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
312 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
313 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
314 Ok(LibraryPurchasesTabTemplate {
315 purchases,
316 subscriptions,
317 })
318 }
319
320 /// Query parameters for the feed, on the one surface that still parses them
321 /// through an extractor. The page is described and reads `?page=` off its own
322 /// carried values.
323 #[derive(Debug, Deserialize)]
324 pub(super) struct FeedQuery {
325 pub page: Option<u32>,
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<FeedQuery>,
334 ) -> Result<impl IntoResponse> {
335 // The panel is described (`crate::quasi::feeds`), so this answers markup
336 // rather than a template, and the arithmetic behind it is `feeds::load`:
337 // the clamp, the i64 widening and the saturating labels are three overflow
338 // fixes that used to be copied here verbatim.
339 let loaded = crate::quasi::feeds::load(&db, user.id, query.page).await?;
340 Ok(axum::response::Html(crate::quasi::feeds::library_fragment(
341 &loaded.page(),
342 )))
343 }
344
345 /// HTMX partial: library collections tab (includes wishlists).
346 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
347 pub(super) async fn library_tab_collections(
348 State(db): State<PgPool>,
349 AuthUser(user): AuthUser,
350 ) -> Result<impl IntoResponse> {
351 let db_collections = db::collections::get_collections_by_user(&db, user.id).await?;
352 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
353 let wishlists = db::wishlists::get_wishlist(&db, user.id).await?;
354 Ok(LibraryCollectionsTabTemplate {
355 collections,
356 username: user.username.to_string(),
357 wishlists,
358 })
359 }
360
361 /// Query params for the login page.
362 #[derive(Deserialize)]
363 pub(crate) struct LoginQuery {
364 /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
365 /// the visitor landed on login instead of the page they requested.
366 pub gate: Option<String>,
367 /// Set by the SSO callback when delegated login fails; shown as an error.
368 pub sso_error: Option<String>,
369 }
370
371 /// Render the login page.
372 #[tracing::instrument(skip_all, name = "landing::login_page")]
373 pub(crate) async fn login_page(
374 State(config): State<Config>,
375 session: Session,
376 ValidatedQuery(query): ValidatedQuery<LoginQuery>,
377 ) -> impl IntoResponse {
378 let sso_enabled = config.sso.is_some();
379 let notice = match query.gate.as_deref() {
380 Some("fan_plus_or_creator") => Some(
381 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
382 .to_string(),
383 ),
384 _ => None,
385 };
386 let csrf_token = get_csrf_token(&session).await;
387 axum::response::Html(crate::quasi::auth_pages::document(
388 csrf_token.as_deref(),
389 &crate::quasi::auth_pages::login(
390 "",
391 query.sso_error.as_deref(),
392 notice.as_deref(),
393 sso_enabled,
394 ),
395 ))
396 }
397
398 #[derive(Deserialize)]
399 pub(super) struct NotifyForm {
400 email: String,
401 }
402
403 /// POST /notify: the landing page's "notify me" capture.
404 ///
405 /// The form posts here directly, so it works with JS off. The page script
406 /// intercepts the submit and posts the same form encoding to the same route,
407 /// which makes it a genuine enhancement (async, inline status, no reload)
408 /// rather than the only path. Before this the form had no action and no
409 /// method, so a browser without JS submitted a GET to `/` and the address was
410 /// dropped without a word to anyone.
411 ///
412 /// Storage is `email_signups`, which already existed for exactly this with a
413 /// `source` column, duplicate collapsing, and an admin view at
414 /// /admin/signups, so nobody has to be told where the addresses went.
415 ///
416 /// A rejected address redirects rather than erroring: this is the last thing
417 /// on the landing page, and a 422 on a marketing form is a worse outcome than
418 /// a sentence saying the address looked wrong.
419 #[tracing::instrument(skip_all, name = "landing::notify")]
420 pub(super) async fn notify(
421 State(db): State<PgPool>,
422 Form(form): Form<NotifyForm>,
423 ) -> Result<impl IntoResponse> {
424 let Ok(email) = db::Email::new(&form.email) else {
425 return Ok(Redirect::to("/?notify=invalid#notify-form"));
426 };
427 db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
428 Ok(Redirect::to("/?notify=ok#notify-form"))
429 }
430
431 /// Render the platform-economics + runway disclosure page.
432 ///
433 /// Served top-level at `/economics` alongside the other landing pages
434 /// (the retired markdown source used to live at `/docs/economics`, which
435 /// now 301s here). Renders as Askama (not docengine markdown) so it can
436 /// carry live figures from the database. The two count queries are cheap
437 /// (each is a single `SELECT COUNT(*)` against an indexed status column);
438 /// no caching needed at current load.
439 #[tracing::instrument(skip_all, name = "landing::economics_page")]
440 pub(super) async fn economics_page(
441 State(db): State<PgPool>,
442 State(payments): State<Billing>,
443 session: Session,
444 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
445 ) -> Result<impl IntoResponse> {
446 let paying_creators = crate::db::creator_tiers::count_active_paying(&db).await?;
447 let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&db).await?;
448 Ok(EconomicsTemplate {
449 csrf_token: get_csrf_token(&session).await,
450 session_user: maybe_user,
451 runway_config: payments.runway_config.clone(),
452 paying_creators,
453 trialing_or_grace,
454 })
455 }
456
457 /// Lightweight checkout success page for app-initiated Stripe flows.
458 /// No auth required; the app polls for subscription status independently.
459 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
460 pub(super) async fn checkout_complete() -> impl IntoResponse {
461 axum::response::Html(
462 r#"<!DOCTYPE html>
463 <html lang="en">
464 <head>
465 <meta charset="UTF-8">
466 <meta name="viewport" content="width=device-width, initial-scale=1.0">
467 <title>Payment Complete | Makenotwork</title>
468 <!-- Standalone head, so the three sheets are listed here rather than
469 coming from crate::shell. style.css reads the bevel pair out of
470 layout.css, so it has to load after it. -->
471 <link rel="stylesheet" href="/static/geometry.css">
472 <link rel="stylesheet" href="/static/layout.css">
473 <link rel="stylesheet" href="/static/style.css">
474 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
475 </head>
476 <body>
477 <main id="main-content">
478 <div class="error-page">
479 <div class="error-container">
480 <h1 class="error-title">Payment complete</h1>
481 <p class="error-message">You can close this tab and return to the app.</p>
482 </div>
483 </div>
484 </main>
485 </body>
486 </html>"#,
487 )
488 }
489