Skip to main content

max / makenotwork

21.0 KB · 566 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, FanPlusTemplate, IndexTemplate,
26 LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate,
27 LibraryTemplate, LoginTemplate, PolicyTemplate, TeamTemplate, UseCasesTemplate,
28 },
29 types::{Collection, 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
173 // The shown panel is rendered here rather than fetched, which is what the
174 // page's `{% include %}` did before the strip was described and is why this
175 // conversion changes the request count by zero. `6b24f2df`.
176 let shown = LibraryPurchasesTabTemplate {
177 purchases,
178 subscriptions,
179 }
180 .render()
181 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
182
183 let can_create_projects = user.can_create_projects;
184 Ok(LibraryTemplate {
185 csrf_token: get_csrf_token(&session).await,
186 session_user: Some(user),
187 tabs: crate::quasi::library_tabs::html(&shown, has_mt_memberships, can_create_projects),
188 })
189 }
190
191 /// Query parameters for the cart page.
192 #[derive(Deserialize)]
193 pub(super) struct CartQuery {
194 pub checkout: Option<String>,
195 }
196
197 /// Render the shopping cart page with items grouped by seller.
198 #[tracing::instrument(skip_all, name = "landing::cart_page")]
199 pub(super) async fn cart_page(
200 State(db): State<PgPool>,
201 session: Session,
202 AuthUser(user): AuthUser,
203 ValidatedQuery(query): ValidatedQuery<CartQuery>,
204 ) -> Result<impl IntoResponse> {
205 use crate::templates::CartSellerGroup;
206 use std::collections::BTreeMap;
207
208 let cart_items = db::cart::get_cart_items(&db, user.id).await?;
209
210 // The buyer's own currency is only a fact once they have connected Stripe;
211 // otherwise `settlement_currency` is just the column default and says
212 // nothing about the card they will pay with.
213 let buyer = db::users::get_user_by_id(&db, user.id).await?;
214 let buyer_currency = buyer
215 .as_ref()
216 .filter(|u| u.stripe_account_id.is_some())
217 .map(|u| u.settlement_currency);
218 let buyer_conversion = buyer
219 .as_ref()
220 .map(|u| u.conversion_preference)
221 .unwrap_or_default();
222
223 // Group by seller
224 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
225 for item in &cart_items {
226 groups
227 .entry(item.seller_id.to_string())
228 .or_default()
229 .push(item.clone());
230 }
231
232 let seller_groups: Vec<CartSellerGroup> = groups
233 .into_iter()
234 .map(|(seller_id_str, items)| {
235 let subtotal_cents: i64 = items
236 .iter()
237 .map(|i| i64::from(i.effective_price_cents()))
238 .sum();
239 let item_count = items.len();
240 // Savings: buying N items in one session saves (N-1) * $0.30
241 let savings_cents = if item_count > 1 {
242 (item_count as i32 - 1) * 30
243 } else {
244 0
245 };
246 let seller_username = items
247 .first()
248 .map(|i| i.creator_username.clone())
249 .unwrap_or_default();
250 let stripe_ready = items
251 .first()
252 .is_some_and(|i| i.seller_stripe_account_id.is_some() && i.seller_charges_enabled);
253
254 // One group is one seller, so the first item's currency is the
255 // group's. An empty group cannot reach here (groups are built by
256 // grouping items), but default rather than panic if that changes.
257 let currency = items
258 .first()
259 .map(|i| i.settlement_currency)
260 .unwrap_or_default();
261
262 CartSellerGroup {
263 offer_conversion_choice: crate::templates::cart_conversion_applies(
264 buyer_currency,
265 currency,
266 ),
267 conversion: buyer_conversion,
268 seller_username,
269 seller_id: seller_id_str,
270 stripe_ready,
271 items,
272 subtotal_cents,
273 item_count,
274 savings_cents,
275 currency,
276 }
277 })
278 .collect();
279
280 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
281
282 // Wishlist suggestions: items in wishlist but not in cart
283 let wishlist = db::wishlists::get_wishlist(&db, user.id).await?;
284 let cart_item_ids: std::collections::HashSet<_> =
285 cart_items.iter().map(|i| i.item_id).collect();
286 let wishlist_suggestions: Vec<_> = wishlist
287 .into_iter()
288 .filter(|w| !cart_item_ids.contains(&w.item_id))
289 .take(10)
290 .collect();
291
292 let offer_conversion_choice = seller_groups.iter().any(|g| g.offer_conversion_choice);
293
294 Ok(CartTemplate {
295 csrf_token: get_csrf_token(&session).await,
296 session_user: Some(user),
297 seller_groups,
298 wishlist_suggestions,
299 total_items,
300 checkout_status: query.checkout.unwrap_or_default(),
301 offer_conversion_choice,
302 conversion: buyer_conversion,
303 })
304 }
305
306 /// HTMX partial: library purchases tab (includes subscriptions).
307 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
308 pub(super) async fn library_tab_purchases(
309 State(db): State<PgPool>,
310 AuthUser(user): AuthUser,
311 ) -> Result<impl IntoResponse> {
312 let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
313 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
314 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
315 Ok(LibraryPurchasesTabTemplate {
316 purchases,
317 subscriptions,
318 })
319 }
320
321 /// Query parameters for the feed, on the one surface that still parses them
322 /// through an extractor. The page is described and reads `?page=` off its own
323 /// carried values.
324 #[derive(Debug, Deserialize)]
325 pub(super) struct FeedQuery {
326 pub page: Option<u32>,
327 }
328
329 /// HTMX partial: library feed tab.
330 #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
331 pub(super) async fn library_tab_feed(
332 State(db): State<PgPool>,
333 AuthUser(user): AuthUser,
334 ValidatedQuery(query): ValidatedQuery<FeedQuery>,
335 ) -> Result<impl IntoResponse> {
336 // The panel is described (`crate::quasi::feeds`), so this answers markup
337 // rather than a template, and the arithmetic behind it is `feeds::load`:
338 // the clamp, the i64 widening and the saturating labels are three overflow
339 // fixes that used to be copied here verbatim.
340 let loaded = crate::quasi::feeds::load(&db, user.id, query.page).await?;
341 Ok(axum::response::Html(crate::quasi::feeds::library_fragment(
342 &loaded.page(),
343 )))
344 }
345
346 /// HTMX partial: library collections tab (includes wishlists).
347 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
348 pub(super) async fn library_tab_collections(
349 State(db): State<PgPool>,
350 AuthUser(user): AuthUser,
351 ) -> Result<impl IntoResponse> {
352 let db_collections = db::collections::get_collections_by_user(&db, user.id).await?;
353 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
354 let wishlists = db::wishlists::get_wishlist(&db, user.id).await?;
355 Ok(LibraryCollectionsTabTemplate {
356 collections,
357 username: user.username.to_string(),
358 wishlists,
359 })
360 }
361
362 /// Query params for the login page.
363 #[derive(Deserialize)]
364 pub(crate) struct LoginQuery {
365 /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
366 /// the visitor landed on login instead of the page they requested.
367 pub gate: Option<String>,
368 /// Set by the SSO callback when delegated login fails; shown as an error.
369 pub sso_error: Option<String>,
370 }
371
372 /// Render the login page.
373 #[tracing::instrument(skip_all, name = "landing::login_page")]
374 pub(crate) async fn login_page(
375 State(config): State<Config>,
376 session: Session,
377 ValidatedQuery(query): ValidatedQuery<LoginQuery>,
378 ) -> impl IntoResponse {
379 let sso_enabled = config.sso.is_some();
380 let notice = match query.gate.as_deref() {
381 Some("fan_plus_or_creator") => Some(
382 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
383 .to_string(),
384 ),
385 _ => None,
386 };
387 LoginTemplate {
388 csrf_token: get_csrf_token(&session).await,
389 prefill_login: String::new(),
390 error: query.sso_error,
391 notice,
392 sso_enabled,
393 }
394 }
395
396 #[derive(Deserialize)]
397 pub(super) struct NotifyForm {
398 email: String,
399 }
400
401 /// POST /notify: the landing page's "notify me" capture.
402 ///
403 /// The form posts here directly, so it works with JS off. The page script
404 /// intercepts the submit and posts the same form encoding to the same route,
405 /// which makes it a genuine enhancement (async, inline status, no reload)
406 /// rather than the only path. Before this the form had no action and no
407 /// method, so a browser without JS submitted a GET to `/` and the address was
408 /// dropped without a word to anyone.
409 ///
410 /// Storage is `email_signups`, which already existed for exactly this with a
411 /// `source` column, duplicate collapsing, and an admin view at
412 /// /admin/signups, so nobody has to be told where the addresses went.
413 ///
414 /// A rejected address redirects rather than erroring: this is the last thing
415 /// on the landing page, and a 422 on a marketing form is a worse outcome than
416 /// a sentence saying the address looked wrong.
417 #[tracing::instrument(skip_all, name = "landing::notify")]
418 pub(super) async fn notify(
419 State(db): State<PgPool>,
420 Form(form): Form<NotifyForm>,
421 ) -> Result<impl IntoResponse> {
422 let Ok(email) = db::Email::new(&form.email) else {
423 return Ok(Redirect::to("/?notify=invalid#notify-form"));
424 };
425 db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
426 Ok(Redirect::to("/?notify=ok#notify-form"))
427 }
428
429 /// Render the platform-economics + runway disclosure page.
430 ///
431 /// Served top-level at `/economics` alongside the other landing pages
432 /// (the retired markdown source used to live at `/docs/economics`, which
433 /// now 301s here). Renders as Askama (not docengine markdown) so it can
434 /// carry live figures from the database. The two count queries are cheap
435 /// (each is a single `SELECT COUNT(*)` against an indexed status column);
436 /// no caching needed at current load.
437 #[tracing::instrument(skip_all, name = "landing::economics_page")]
438 pub(super) async fn economics_page(
439 State(db): State<PgPool>,
440 State(payments): State<Billing>,
441 session: Session,
442 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
443 ) -> Result<impl IntoResponse> {
444 let paying_creators = crate::db::creator_tiers::count_active_paying(&db).await?;
445 let trialing_or_grace = crate::db::creator_tiers::count_trialing_or_grace(&db).await?;
446 Ok(EconomicsTemplate {
447 csrf_token: get_csrf_token(&session).await,
448 session_user: maybe_user,
449 runway_config: payments.runway_config.clone(),
450 paying_creators,
451 trialing_or_grace,
452 })
453 }
454
455 /// Lightweight checkout success page for app-initiated Stripe flows.
456 /// No auth required; the app polls for subscription status independently.
457 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
458 pub(super) async fn checkout_complete() -> impl IntoResponse {
459 axum::response::Html(
460 r#"<!DOCTYPE html>
461 <html lang="en">
462 <head>
463 <meta charset="UTF-8">
464 <meta name="viewport" content="width=device-width, initial-scale=1.0">
465 <title>Payment Complete | Makenotwork</title>
466 <!-- Standalone head, so the three sheets are listed here rather than
467 coming from crate::shell. style.css reads the bevel pair out of
468 layout.css, so it has to load after it. -->
469 <link rel="stylesheet" href="/static/geometry.css">
470 <link rel="stylesheet" href="/static/layout.css">
471 <link rel="stylesheet" href="/static/style.css">
472 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
473 </head>
474 <body>
475 <main id="main-content">
476 <div class="error-page">
477 <div class="error-container">
478 <h1 class="error-title">Payment complete</h1>
479 <p class="error-message">You can close this tab and return to the app.</p>
480 </div>
481 </div>
482 </main>
483 </body>
484 </html>"#,
485 )
486 }
487
488 /// Render the use cases page.
489 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
490 pub(super) async fn use_cases_page(
491 State(payments): State<Billing>,
492 session: Session,
493 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
494 ) -> impl IntoResponse {
495 UseCasesTemplate {
496 csrf_token: get_csrf_token(&session).await,
497 session_user: maybe_user,
498 tier_prices: payments.tier_prices.clone(),
499 }
500 }
501
502 /// Render the team page.
503 #[tracing::instrument(skip_all, name = "landing::team_page")]
504 pub(super) async fn team_page(
505 session: Session,
506 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
507 ) -> impl IntoResponse {
508 TeamTemplate {
509 csrf_token: get_csrf_token(&session).await,
510 session_user: maybe_user,
511 }
512 }
513
514 /// Render the content policy page.
515 #[tracing::instrument(skip_all, name = "landing::policy_page")]
516 pub(super) async fn policy_page(
517 session: Session,
518 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
519 ) -> impl IntoResponse {
520 let csrf_token = get_csrf_token(&session).await;
521 PolicyTemplate {
522 csrf_token,
523 session_user: maybe_user,
524 }
525 }
526
527 /// Query params for the Fan+ page.
528 #[derive(Debug, Deserialize)]
529 pub(super) struct FanPlusQuery {
530 pub subscribed: Option<bool>,
531 }
532
533 /// Render the Fan+ subscription page.
534 #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
535 pub(super) async fn fan_plus_page(
536 State(db): State<PgPool>,
537 session: Session,
538 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
539 ValidatedQuery(query): ValidatedQuery<FanPlusQuery>,
540 ) -> Result<impl IntoResponse> {
541 let csrf_token = get_csrf_token(&session).await;
542
543 let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
544 let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?;
545 match fan_sub {
546 Some(sub) if sub.status == "active" => {
547 let end = sub
548 .current_period_end
549 .map(|d| d.format("%B %-d, %Y").to_string());
550 (true, end)
551 }
552 _ => (false, None),
553 }
554 } else {
555 (false, None)
556 };
557
558 Ok(FanPlusTemplate {
559 csrf_token,
560 session_user: maybe_user,
561 is_subscribed,
562 period_end,
563 just_subscribed: query.subscribed.unwrap_or(false),
564 })
565 }
566