Skip to main content

max / makenotwork

16.1 KB · 467 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 // Surface remaining founder slots only when close enough to feel
49 // scarce. 200 is "last chunk" — enough warning to convert, not so
50 // early that the number stays prominent for months.
51 let founder_window_open = state.config.creator_founder_window_open;
52 const FOUNDER_CAP: u32 = 1_000;
53 const URGENCY_THRESHOLD: u32 = 200;
54 let founder_slots_remaining = if founder_window_open && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD) {
55 Some(FOUNDER_CAP.saturating_sub(total_creators))
56 } else {
57 None
58 };
59
60 Ok(IndexTemplate {
61 csrf_token: get_csrf_token(&session).await,
62 host_url: state.config.host_url.clone(),
63 total_creators,
64 total_items: total_items as u32,
65 founder_window_open,
66 founder_slots_remaining,
67 tier_prices: state.tier_prices.clone(),
68 }.into_response())
69 }
70 }
71 }
72
73 /// Render the authenticated user's library with inline purchases tab.
74 #[tracing::instrument(skip_all, name = "landing::library")]
75 pub(super) async fn library(
76 State(state): State<AppState>,
77 session: Session,
78 AuthUser(user): AuthUser,
79 ) -> Result<impl IntoResponse> {
80 let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
81 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
82 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
83 let has_mt_memberships = state.config.mt_base_url.is_some();
84 Ok(LibraryTemplate {
85 csrf_token: get_csrf_token(&session).await,
86 session_user: Some(user),
87 purchases,
88 subscriptions,
89 has_mt_memberships,
90 })
91 }
92
93 /// Query parameters for the cart page.
94 #[derive(Deserialize)]
95 pub(super) struct CartQuery {
96 pub checkout: Option<String>,
97 }
98
99 /// Render the shopping cart page with items grouped by seller.
100 #[tracing::instrument(skip_all, name = "landing::cart_page")]
101 pub(super) async fn cart_page(
102 State(state): State<AppState>,
103 session: Session,
104 AuthUser(user): AuthUser,
105 Query(query): Query<CartQuery>,
106 ) -> Result<impl IntoResponse> {
107 use std::collections::BTreeMap;
108 use crate::templates::CartSellerGroup;
109
110 let cart_items = db::cart::get_cart_items(&state.db, user.id).await?;
111
112 // Group by seller
113 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
114 for item in cart_items.iter() {
115 groups
116 .entry(item.seller_id.to_string())
117 .or_default()
118 .push(item.clone());
119 }
120
121 let seller_groups: Vec<CartSellerGroup> = groups
122 .into_iter()
123 .map(|(seller_id_str, items)| {
124 let subtotal_cents: i32 = items.iter().map(|i| i.effective_price_cents()).sum();
125 let item_count = items.len();
126 // Savings: buying N items in one session saves (N-1) * $0.30
127 let savings_cents = if item_count > 1 { (item_count as i32 - 1) * 30 } else { 0 };
128 let seller_username = items.first().map(|i| i.creator_username.clone()).unwrap_or_default();
129 let stripe_ready = items.first().map(|i| {
130 i.seller_stripe_account_id.is_some() && i.seller_charges_enabled
131 }).unwrap_or(false);
132
133 CartSellerGroup {
134 seller_username,
135 seller_id: seller_id_str,
136 stripe_ready,
137 items,
138 subtotal_cents,
139 item_count,
140 savings_cents,
141 }
142 })
143 .collect();
144
145 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
146
147 // Wishlist suggestions: items in wishlist but not in cart
148 let wishlist = db::wishlists::get_wishlist(&state.db, user.id).await?;
149 let cart_item_ids: std::collections::HashSet<_> = cart_items.iter().map(|i| i.item_id).collect();
150 let wishlist_suggestions: Vec<_> = wishlist
151 .into_iter()
152 .filter(|w| !cart_item_ids.contains(&w.item_id))
153 .take(10)
154 .collect();
155
156 Ok(CartTemplate {
157 csrf_token: get_csrf_token(&session).await,
158 session_user: Some(user),
159 seller_groups,
160 wishlist_suggestions,
161 total_items,
162 checkout_status: query.checkout.unwrap_or_default(),
163 })
164 }
165
166 /// HTMX partial: library purchases tab (includes subscriptions).
167 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
168 pub(super) async fn library_tab_purchases(
169 State(state): State<AppState>,
170 AuthUser(user): AuthUser,
171 ) -> Result<impl IntoResponse> {
172 let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
173 let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
174 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
175 Ok(LibraryPurchasesTabTemplate { purchases, subscriptions })
176 }
177
178 /// HTMX partial: library feed tab.
179 #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
180 pub(super) async fn library_tab_feed(
181 State(state): State<AppState>,
182 AuthUser(user): AuthUser,
183 Query(query): Query<super::feed::FeedQuery>,
184 ) -> Result<impl IntoResponse> {
185 use crate::templates::LibraryFeedTabTemplate;
186
187 let page = query.page.unwrap_or(1).max(1);
188 let offset = ((page - 1) * constants::FEED_PAGE_SIZE) as i64;
189
190 let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32;
191 let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
192
193 let db_items = db::follows::get_followed_feed_items(
194 &state.db,
195 user.id,
196 constants::FEED_PAGE_SIZE as i64,
197 offset,
198 )
199 .await?;
200
201 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
202
203 let showing_start = if total_items == 0 { 0 } else { offset as u32 + 1 };
204 let showing_end = (offset as u32 + constants::FEED_PAGE_SIZE).min(total_items);
205 let pagination_range = super::feed::build_pagination_range(page, total_pages);
206
207 Ok(LibraryFeedTabTemplate {
208 items,
209 total_items,
210 current_page: page,
211 total_pages,
212 pagination_range,
213 showing_start,
214 showing_end,
215 })
216 }
217
218 /// HTMX partial: library collections tab (includes wishlists).
219 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
220 pub(super) async fn library_tab_collections(
221 State(state): State<AppState>,
222 AuthUser(user): AuthUser,
223 ) -> Result<impl IntoResponse> {
224 let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
225 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
226 let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?;
227 Ok(LibraryCollectionsTabTemplate {
228 collections,
229 username: user.username.to_string(),
230 wishlists,
231 })
232 }
233
234 /// HTMX partial: library contacts tab.
235 #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")]
236 pub(super) async fn library_tab_contacts(
237 State(state): State<AppState>,
238 AuthUser(user): AuthUser,
239 ) -> Result<impl IntoResponse> {
240 let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?;
241
242 // Fetch seller contacts (buyers who shared their email) if user is a creator
243 let db_user = db::users::get_user_by_id(&state.db, user.id)
244 .await?
245 .ok_or(AppError::NotFound)?;
246 let db_contacts = if db_user.can_create_projects {
247 db::transactions::get_seller_contacts(&state.db, user.id).await?
248 } else {
249 vec![]
250 };
251 let total_buyer_contacts = db_contacts.len();
252 let buyer_contacts: Vec<ContactRow> = db_contacts
253 .into_iter()
254 .map(|c| ContactRow {
255 username: c.username,
256 email: c.email,
257 total_purchases: c.total_purchases,
258 total_spent: helpers::format_revenue(c.total_spent_cents),
259 last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
260 })
261 .collect();
262
263 Ok(LibraryContactsTabTemplate { shared_creators, buyer_contacts, total_buyer_contacts })
264 }
265
266 /// HTMX partial: library communities tab (Multithreaded forum memberships).
267 #[tracing::instrument(skip_all, name = "landing::library_tab_communities")]
268 pub(super) async fn library_tab_communities(
269 State(state): State<AppState>,
270 AuthUser(user): AuthUser,
271 ) -> Result<axum::response::Response> {
272 let mt_base_url = match state.config.mt_base_url.as_ref() {
273 Some(url) => url,
274 None => {
275 return Ok(LibraryCommunitiesTabTemplate {
276 memberships: vec![],
277 mt_base_url: String::new(),
278 }
279 .into_response())
280 }
281 };
282
283 let url = format!("{}/api/user/{}/summary", mt_base_url, user.id);
284
285 let resp = reqwest::Client::new()
286 .get(&url)
287 .timeout(std::time::Duration::from_secs(5))
288 .send()
289 .await
290 .map_err(|e| {
291 tracing::warn!(error = ?e, "failed to fetch MT user summary");
292 AppError::Internal(anyhow::anyhow!("MT API unavailable"))
293 })?;
294
295 if !resp.status().is_success() {
296 return Ok(LibraryCommunitiesTabTemplate {
297 memberships: vec![],
298 mt_base_url: mt_base_url.clone(),
299 }
300 .into_response());
301 }
302
303 let json: serde_json::Value = resp.json().await.map_err(|e| {
304 tracing::warn!(error = ?e, "failed to parse MT summary response");
305 AppError::Internal(anyhow::anyhow!("MT API response invalid"))
306 })?;
307
308 let memberships = json["memberships"]
309 .as_array()
310 .map(|arr| {
311 arr.iter()
312 .filter_map(|m| {
313 let community_slug = m["community_slug"].as_str()?;
314 Some(ForumMembership {
315 community_name: m["community_name"].as_str()?.to_string(),
316 profile_url: format!(
317 "{}/p/{}/u/{}",
318 mt_base_url, community_slug, user.username
319 ),
320 role: m["role"].as_str()?.to_string(),
321 joined: m["joined_at"]
322 .as_str()
323 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
324 .map(|dt| dt.format("%b %d, %Y").to_string())
325 .unwrap_or_default(),
326 post_count: m["post_count"].as_i64().unwrap_or(0),
327 })
328 })
329 .collect()
330 })
331 .unwrap_or_default();
332
333 Ok(LibraryCommunitiesTabTemplate {
334 memberships,
335 mt_base_url: mt_base_url.clone(),
336 }
337 .into_response())
338 }
339
340 /// Render the login page.
341 #[tracing::instrument(skip_all, name = "landing::login_page")]
342 pub(crate) async fn login_page(session: Session) -> impl IntoResponse {
343 LoginTemplate {
344 csrf_token: get_csrf_token(&session).await,
345 prefill_login: String::new(),
346 error: None,
347 }
348 }
349
350 /// Render the interactive pricing calculator page.
351 #[tracing::instrument(skip_all, name = "landing::pricing_page")]
352 pub(super) async fn pricing_page(
353 State(state): State<AppState>,
354 session: Session,
355 ) -> impl IntoResponse {
356 PricingTemplate {
357 csrf_token: get_csrf_token(&session).await,
358 tier_prices: state.tier_prices.clone(),
359 }
360 }
361
362 /// Lightweight checkout success page for app-initiated Stripe flows.
363 /// No auth required; the app polls for subscription status independently.
364 #[tracing::instrument(skip_all, name = "landing::checkout_complete")]
365 pub(super) async fn checkout_complete() -> impl IntoResponse {
366 axum::response::Html(
367 r#"<!DOCTYPE html>
368 <html lang="en">
369 <head>
370 <meta charset="UTF-8">
371 <meta name="viewport" content="width=device-width, initial-scale=1.0">
372 <title>Payment Complete | Makenot.work</title>
373 <link rel="stylesheet" href="/static/style.css">
374 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
375 </head>
376 <body>
377 <main id="main-content">
378 <div class="error-page">
379 <div class="error-container">
380 <h1 class="error-title">Payment complete</h1>
381 <p class="error-message">You can close this tab and return to the app.</p>
382 </div>
383 </div>
384 </main>
385 </body>
386 </html>"#,
387 )
388 }
389
390 /// Render the use cases page.
391 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
392 pub(super) async fn use_cases_page(
393 State(state): State<AppState>,
394 session: Session,
395 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
396 ) -> impl IntoResponse {
397 UseCasesTemplate {
398 csrf_token: get_csrf_token(&session).await,
399 session_user: maybe_user,
400 tier_prices: state.tier_prices.clone(),
401 }
402 }
403
404 /// Render the team page.
405 #[tracing::instrument(skip_all, name = "landing::team_page")]
406 pub(super) async fn team_page(
407 session: Session,
408 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
409 ) -> impl IntoResponse {
410 TeamTemplate {
411 csrf_token: get_csrf_token(&session).await,
412 session_user: maybe_user,
413 }
414 }
415
416 /// Render the content policy page.
417 #[tracing::instrument(skip_all, name = "landing::policy_page")]
418 pub(super) async fn policy_page(
419 session: Session,
420 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
421 ) -> impl IntoResponse {
422 let csrf_token = get_csrf_token(&session).await;
423 PolicyTemplate {
424 csrf_token,
425 session_user: maybe_user,
426 }
427 }
428
429
430 /// Query params for the Fan+ page.
431 #[derive(Debug, Deserialize)]
432 pub(super) struct FanPlusQuery {
433 pub subscribed: Option<bool>,
434 }
435
436 /// Render the Fan+ subscription page.
437 #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
438 pub(super) async fn fan_plus_page(
439 State(state): State<AppState>,
440 session: Session,
441 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
442 Query(query): Query<FanPlusQuery>,
443 ) -> Result<impl IntoResponse> {
444 let csrf_token = get_csrf_token(&session).await;
445
446 let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
447 let fan_sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id).await?;
448 match fan_sub {
449 Some(sub) if sub.status == "active" => {
450 let end = sub.current_period_end.map(|d| d.format("%B %-d, %Y").to_string());
451 (true, end)
452 }
453 _ => (false, None),
454 }
455 } else {
456 (false, None)
457 };
458
459 Ok(FanPlusTemplate {
460 csrf_token,
461 session_user: maybe_user,
462 is_subscribed,
463 period_end,
464 just_subscribed: query.subscribed.unwrap_or(false),
465 })
466 }
467