Skip to main content

max / makenotwork

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