Skip to main content

max / makenotwork

39.2 KB · 1062 lines History Blame Raw
1 //! Templates for public-facing pages: landing, auth, content, blog, discover.
2 //!
3 //! Git source-browser templates live in the `git` submodule and are
4 //! re-exported flat, call sites still see `templates::GitRepoTemplate` etc.
5
6 mod git;
7 mod health;
8
9 pub use git::*;
10 pub use health::*;
11
12 use std::sync::Arc;
13
14 use askama::Template;
15
16 use crate::auth::SessionUser;
17 use crate::types::{
18 BlogPostSummary, Chapter, Collection, CollectionItem, CustomLink, DiscoverItem,
19 DiscoverProject, Item, ItemContent, ItemSection, Project, SidebarView, SubscriptionTier,
20 TagBreadcrumb, TagTreeNode, User, Version,
21 };
22
23 use super::CsrfTokenOption;
24
25 // Public Pages
26
27 /// Sandbox info page explaining the ephemeral demo mode.
28 #[derive(Template)]
29 #[template(path = "pages/sandbox.html")]
30 pub struct SandboxTemplate {
31 pub csrf_token: CsrfTokenOption,
32 }
33
34 /// Content policy page.
35 #[derive(Template)]
36 #[template(path = "pages/policy.html")]
37 pub struct PolicyTemplate {
38 /// CSRF token injected into forms; `None` on public pages that have no forms.
39 pub csrf_token: CsrfTokenOption,
40 /// Logged-in user context for the site header; `None` when not authenticated.
41 pub session_user: Option<SessionUser>,
42 }
43
44 /// Landing page.
45 #[derive(Template)]
46 #[template(path = "pages/index.html")]
47 pub struct IndexTemplate {
48 pub csrf_token: CsrfTokenOption,
49 pub host_url: Arc<str>,
50 pub total_creators: u32,
51 pub total_items: u32,
52 /// Whether the founder pricing window is currently open. When true the
53 /// landing page features the founder rate prominently and links to /docs
54 /// /guide/tiers.
55 pub founder_window_open: bool,
56 /// Remaining founder slots (1,000 cap). Only shown when small enough to
57 /// convey urgency; not exposed when comfortably above the cap.
58 pub founder_slots_remaining: Option<u32>,
59 pub tier_prices: crate::tier_prices::TierPrices,
60 /// Screenshot frames for the "see the platform" carousel. Currently
61 /// placeholders; swap the images (and tighten the alt text) once real
62 /// captures exist. Empty hides the section.
63 pub landing_carousel: Vec<super::CarouselFrame>,
64 /// The "Last shipped" velocity line, drawn from the most recent published,
65 /// landing-flagged changelog post. `None` suppresses the line entirely
66 /// (no placeholder), same honesty rule the runway disclosure uses.
67 pub last_shipped: Option<LandingVelocity>,
68 /// Result of a no-JS notify submission, carried back through the redirect:
69 /// `Some(true)` subscribed, `Some(false)` the address was rejected, `None`
70 /// a plain page load. The JS path renders its status inline instead and
71 /// leaves this `None`.
72 pub notify_ok: Option<bool>,
73 }
74
75 /// One-line "Last shipped" velocity signal for the landing page.
76 pub struct LandingVelocity {
77 /// Post title.
78 pub title: String,
79 /// Publication date, preformatted (e.g. "Jun 07, 2026").
80 pub date: String,
81 /// Link target: `/changelog/{slug}`.
82 pub href: String,
83 }
84
85 /// User's library shell with inline purchases tab (other tabs loaded via HTMX).
86 #[derive(Template)]
87 #[template(path = "pages/library.html")]
88 pub struct LibraryTemplate {
89 pub csrf_token: CsrfTokenOption,
90 pub session_user: Option<SessionUser>,
91 /// The tab strip and its panels, described rather than written out here.
92 ///
93 /// `6b24f2df`. Built by `crate::quasi::library_tabs`, which needs the shown
94 /// panel's markup and the two membership tests, so the handler assembles it
95 /// and this carries the answer. `purchases` and `subscriptions` left with
96 /// the strip: the only thing that read them was the include that is now
97 /// inside it.
98 pub tabs: String,
99 }
100
101 /// Shopping cart page with items grouped by seller.
102 #[derive(Template)]
103 #[template(path = "pages/cart.html")]
104 pub struct CartTemplate {
105 pub csrf_token: CsrfTokenOption,
106 pub session_user: Option<SessionUser>,
107 pub seller_groups: Vec<CartSellerGroup>,
108 pub wishlist_suggestions: Vec<crate::db::wishlists::WishlistItem>,
109 pub total_items: usize,
110 /// Set to "partial" when a multi-seller checkout partially succeeded.
111 pub checkout_status: String,
112 /// Whether any seller in the cart might need converting for this buyer.
113 pub offer_conversion_choice: bool,
114 /// The buyer's stored conversion preference.
115 pub conversion: crate::currency::ConversionChoice,
116 }
117
118 impl CartTemplate {
119 /// Whether the stored preference is convert-at-checkout.
120 pub fn conversion_at_checkout(&self) -> bool {
121 matches!(
122 self.conversion,
123 crate::currency::ConversionChoice::AtCheckout
124 )
125 }
126 }
127
128 /// A group of cart items from the same seller.
129 pub struct CartSellerGroup {
130 pub seller_username: String,
131 pub seller_id: String,
132 pub stripe_ready: bool,
133 pub items: Vec<crate::db::cart::CartItem>,
134 /// i64 so a large cart can't overflow when per-item cents are summed (Pay-M3);
135 /// the per-item price is i32 but the running total is widened.
136 pub subtotal_cents: i64,
137 pub item_count: usize,
138 /// How much the creator saves vs. individual purchases ($0.30 per extra item).
139 pub savings_cents: i32,
140 /// The seller's settlement currency. One group is one seller, so one
141 /// currency, which is also why a cart never needs splitting on currency.
142 pub currency: crate::currency::SettlementCurrency,
143 /// Whether to offer the buyer a conversion choice for this seller.
144 ///
145 /// False only when we positively know the buyer's own currency and it
146 /// matches, in which case there is nothing to convert and the control would
147 /// be noise. See `cart_conversion_applies` for why "positively know" is
148 /// narrower than it sounds.
149 pub offer_conversion_choice: bool,
150 /// The buyer's stored preference, so the form comes back the way they left
151 /// it rather than resetting to the default on every visit.
152 pub conversion: crate::currency::ConversionChoice,
153 }
154
155 /// Whether a conversion choice is worth showing for a seller in this currency.
156 ///
157 /// `buyer_currency` is `Some` only when MNW actually knows it, which is a
158 /// narrower thing than it looks: `settlement_currency` defaults to USD for every
159 /// account, so it is evidence of a buyer's own currency only once they have
160 /// connected Stripe. For everyone else it is a default, not a fact, and treating
161 /// it as one would hide the control from exactly the UK fan who needs it.
162 ///
163 /// So the rule is: hide only on positive knowledge of a match, otherwise offer
164 /// the choice and word it conditionally. MNW cannot know a card's currency in
165 /// advance, and Stripe itself only decides presentment at checkout time.
166 pub fn cart_conversion_applies(
167 buyer_currency: Option<crate::currency::SettlementCurrency>,
168 seller_currency: crate::currency::SettlementCurrency,
169 ) -> bool {
170 buyer_currency != Some(seller_currency)
171 }
172
173 impl CartSellerGroup {
174 /// Whether this group's prices are in a currency the buyer might not hold.
175 pub fn conversion_at_checkout(&self) -> bool {
176 matches!(
177 self.conversion,
178 crate::currency::ConversionChoice::AtCheckout
179 )
180 }
181
182 /// The seller's currency symbol, for amounts the template frames itself
183 /// (the PWYW input prefix, the zero platform fee).
184 pub fn currency_symbol(&self) -> &'static str {
185 self.currency.symbol()
186 }
187
188 pub fn subtotal_display(&self) -> String {
189 crate::formatting::format_revenue(self.subtotal_cents, self.currency)
190 }
191
192 pub fn savings_display(&self) -> String {
193 crate::formatting::format_revenue(self.savings_cents as i64, self.currency)
194 }
195 }
196
197 /// Login page.
198 #[derive(Template)]
199 #[template(path = "pages/login.html")]
200 pub struct LoginTemplate {
201 pub csrf_token: CsrfTokenOption,
202 /// Re-displayed in the username/email input on validation failure so the
203 /// user doesn't have to retype it. Empty on the first GET.
204 pub prefill_login: String,
205 /// Shown inline above the form on a failed POST. None hides the banner.
206 pub error: Option<String>,
207 /// Neutral informational notice above the form (e.g. the access-gate prompt
208 /// on the testnot staging mirror). None hides it. Separate from `error` so
209 /// it doesn't render as a failure.
210 pub notice: Option<String>,
211 /// When true, the page shows a single "Sign in with Makenotwork" button
212 /// (delegated SSO) instead of the local password form. Set on the testnot
213 /// mirror where `[sso]` is configured.
214 pub sso_enabled: bool,
215 }
216
217 // Join Wizard
218
219 /// Full page: join/signup wizard.
220 #[derive(Template)]
221 #[template(path = "wizards/wizard_join.html")]
222 pub struct WizardJoinTemplate {
223 pub csrf_token: CsrfTokenOption,
224 pub nav: Vec<super::StepNavItem>,
225 pub invite_code: Option<String>,
226 /// Preserved input + error on a non-HTMX account-step re-render (so a
227 /// JS-disabled submit that fails validation doesn't drop the typed
228 /// username/email). Empty / `None` on the initial render.
229 pub username: String,
230 pub email: String,
231 pub error: Option<String>,
232 pub error_field: Option<String>,
233 }
234
235 /// Step 1 partial: account creation (back-nav reload, and the HTMX validation
236 /// re-render). Carries preserved input + error so a failed HTMX submit swaps the
237 /// form back with the typed username/email intact and the bad field flagged,
238 /// instead of replacing the whole step with a bare error line.
239 #[derive(Template)]
240 #[template(path = "wizards/steps/join/account.html")]
241 pub struct WizardJoinAccountTemplate {
242 pub nav: Vec<super::StepNavItem>,
243 pub csrf_token: CsrfTokenOption,
244 pub invite_code: Option<String>,
245 pub username: String,
246 pub email: String,
247 pub error: Option<String>,
248 pub error_field: Option<String>,
249 }
250
251 /// Step 2 partial: profile (display name + bio).
252 #[derive(Template)]
253 #[template(path = "wizards/steps/join/profile.html")]
254 pub struct WizardJoinProfileTemplate {
255 pub nav: Vec<super::StepNavItem>,
256 }
257
258 /// Step 3 partial: welcome/complete with intent branching.
259 #[derive(Template)]
260 #[template(path = "wizards/steps/join/complete.html")]
261 pub struct WizardJoinCompleteTemplate {
262 pub nav: Vec<super::StepNavItem>,
263 pub display_name: String,
264 /// Whether this user already has creator access.
265 pub is_creator: bool,
266 /// Whether this user arrived via invite (already has waitlist entry).
267 pub has_invite: bool,
268 }
269
270 /// Two-factor authentication verification page (login flow).
271 #[derive(Template)]
272 #[template(path = "pages/two_factor.html")]
273 pub struct TwoFactorTemplate {
274 pub csrf_token: CsrfTokenOption,
275 pub session_user: Option<SessionUser>,
276 pub error: Option<String>,
277 }
278
279 /// OAuth2 authorization / consent page.
280 #[derive(Template)]
281 #[template(path = "pages/oauth_authorize.html")]
282 pub struct OAuthAuthorizeTemplate {
283 pub csrf_token: CsrfTokenOption,
284 pub session_user: Option<SessionUser>,
285 pub app_name: String,
286 pub client_id: String,
287 pub redirect_uri: String,
288 pub state: String,
289 pub code_challenge: String,
290 pub code_challenge_method: String,
291 /// Space-delimited requested scope, round-tripped through the consent form
292 /// so the granted scope survives the GET -> POST hop.
293 pub scope: String,
294 pub error_message: Option<String>,
295 }
296
297 /// Forgot password form.
298 #[derive(Template)]
299 #[template(path = "pages/forgot_password.html")]
300 pub struct ForgotPasswordTemplate {
301 pub csrf_token: CsrfTokenOption,
302 }
303
304 /// Password reset form (reached via email link).
305 #[derive(Template)]
306 #[template(path = "pages/reset_password.html")]
307 pub struct ResetPasswordTemplate {
308 pub csrf_token: CsrfTokenOption,
309 pub valid: bool,
310 /// The single-use reset token, round-tripped through the form's hidden
311 /// field so the POST can consume it. Empty when the link is invalid.
312 pub token: String,
313 /// Inline error banner (e.g. "Passwords do not match"). None hides the
314 /// banner. Used on non-HTMX form-validation failures so the user stays
315 /// on the form with the token field intact.
316 pub error: Option<String>,
317 }
318
319 /// Public user profile page.
320 #[derive(Template)]
321 #[template(path = "pages/user.html")]
322 #[allow(dead_code)] // Fields used by Askama template
323 pub struct UserTemplate {
324 pub csrf_token: CsrfTokenOption,
325 pub session_user: Option<SessionUser>,
326 pub user: User,
327 pub custom_links: Vec<CustomLink>,
328 pub projects: Vec<Project>,
329 pub public_collections: Vec<Collection>,
330 /// User ID for the follow button target.
331 pub user_id: String,
332 /// Whether the current viewer is looking at their own profile.
333 pub is_own_profile: bool,
334 /// Whether the current viewer is following this user.
335 pub is_following: bool,
336 /// Total follower count for this user.
337 pub follower_count: i64,
338 /// Base URL for OG meta tags.
339 pub host_url: Arc<str>,
340 /// Whether this creator has voluntarily paused their account.
341 pub creator_paused: bool,
342 /// Whether this creator accepts tips.
343 pub tips_enabled: bool,
344 /// Creator's user ID for tip checkout (string for template use).
345 pub creator_id: String,
346 /// Project ID for tip attribution (None on user profile pages).
347 pub tip_project_id: Option<String>,
348 /// Pre-rendered primitive-layer CSS for this creator's chosen theme,
349 /// injected into the page `<head>` (Tier 0). See `crate::theming`.
350 pub theme_css: &'static str,
351 }
352
353 /// Public collection page (shareable URL).
354 #[derive(Template)]
355 #[template(path = "pages/collection.html")]
356 #[allow(dead_code)] // Fields used by Askama template
357 pub struct CollectionTemplate {
358 pub csrf_token: CsrfTokenOption,
359 pub session_user: Option<SessionUser>,
360 pub collection: Collection,
361 pub items: Vec<CollectionItem>,
362 pub owner_username: String,
363 pub owner_display_name: Option<String>,
364 pub is_owner: bool,
365 }
366
367 /// Public project page with item listing.
368 #[derive(Template)]
369 #[template(path = "pages/project.html")]
370 #[allow(dead_code)] // Fields used by Askama template
371 pub struct ProjectTemplate {
372 pub csrf_token: CsrfTokenOption,
373 pub session_user: Option<SessionUser>,
374 pub project: Project,
375 pub creator_username: String,
376 pub items: Vec<Item>,
377 /// Project ID for the follow button target.
378 pub project_id: String,
379 /// Whether the current viewer is following this project.
380 pub is_following: bool,
381 /// Total follower count for this project.
382 pub follower_count: i64,
383 /// Active subscription tiers available for this project.
384 pub subscription_tiers: Vec<SubscriptionTier>,
385 /// Whether the current viewer already has an active subscription.
386 pub has_subscription: bool,
387 /// Base URL for OG meta tags.
388 pub host_url: Arc<str>,
389 /// Linked git repositories: (name, URL) pairs.
390 pub git_repos: Vec<(String, String)>,
391 /// Whether this project has any published blog posts.
392 pub has_blog_posts: bool,
393 /// URL to the paired MT community forum (None if no community provisioned).
394 pub community_url: Option<String>,
395 /// Whether the project owner accepts tips.
396 pub tips_enabled: bool,
397 /// Creator's user ID for tip checkout (string for template use).
398 pub creator_id: String,
399 /// Project ID for tip attribution.
400 pub tip_project_id: Option<String>,
401 /// Whether the current viewer owns this project.
402 pub is_owner: bool,
403 /// Tabbed markdown sections (privacy, terms, FAQ, etc).
404 pub sections: Vec<crate::types::ProjectSection>,
405 /// Ordered gallery images rendered through the shared carousel widget
406 /// (empty → the carousel section is suppressed). Additive to cover_image_url.
407 pub gallery: Vec<super::CarouselFrame>,
408 /// Pre-rendered primitive-layer CSS for this project's chosen theme,
409 /// injected into the page `<head>` (Tier 0). See `crate::theming`.
410 pub theme_css: &'static str,
411 }
412
413 /// Project paywall landing page (shown when a project requires purchase/subscription).
414 #[derive(Template)]
415 #[template(path = "pages/project_paywall.html")]
416 pub struct ProjectPaywallTemplate {
417 pub csrf_token: CsrfTokenOption,
418 pub session_user: Option<SessionUser>,
419 pub project: Project,
420 pub creator_username: String,
421 /// Human-readable pricing (e.g. "$19.99", "Subscription").
422 pub price_display: String,
423 /// What kind of checkout flow is needed.
424 pub checkout_type: crate::pricing::CheckoutType,
425 /// The pay-what-you-want minimum as a plain decimal ("0.00", "9.99"), for
426 /// the `min` on the dollars input. Zero for every other checkout type.
427 pub pwyw_min_dollars: String,
428 /// Available subscription tiers (for subscription-model projects).
429 pub subscription_tiers: Vec<SubscriptionTier>,
430 /// Base URL for OG meta tags.
431 pub host_url: Arc<str>,
432 }
433
434 /// Public item detail page.
435 #[derive(Template)]
436 #[template(path = "pages/item.html")]
437 #[allow(dead_code)] // Fields used by Askama template
438 pub struct ItemTemplate {
439 pub csrf_token: CsrfTokenOption,
440 pub session_user: Option<SessionUser>,
441 pub item: Item,
442 pub creator_username: String,
443 /// Uppercase ISO code for the JSON-LD `priceCurrency`. Structured data is
444 /// read by machines that will not second-guess it, so a hardcoded USD here
445 /// would misprice a non-USD creator's work in search results.
446 pub price_currency: &'static str,
447 pub project_title: String,
448 pub project_slug: String,
449 /// Base URL for OG meta tags.
450 pub host_url: Arc<str>,
451 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
452 pub discussion_url: Option<String>,
453 /// Number of posts in the linked discussion thread.
454 pub discussion_count: Option<i64>,
455 /// Project cover image URL (fallback for og:image when item has no cover).
456 pub project_cover_image_url: Option<String>,
457 /// Child items for bundle-type items (empty for non-bundles).
458 pub bundle_items: Vec<Item>,
459 /// Bundles containing this item (for unlisted items, to show "Available in" links).
460 pub containing_bundles: Vec<Item>,
461 /// Tabbed content sections (e.g. Features, Installation, Specs).
462 pub sections: Vec<ItemSection>,
463 /// Whether the current user is the item's creator (for dashboard links).
464 pub is_owner: bool,
465 /// Whether the current user has wishlisted this item.
466 pub is_wishlisted: bool,
467 /// Whether the current user has this item in their cart.
468 pub in_cart: bool,
469 /// How many of the current user's collections contain this item.
470 pub collection_count: u32,
471 /// Whether the current user can consume this item (purchased, free, subscribed, creator, bundle).
472 /// Drives the store-page CTA swap: true → "View in library", false → Buy/PWYW.
473 pub has_access: bool,
474 /// Ordered gallery images rendered through the shared carousel widget
475 /// (empty → the carousel section is suppressed). Additive to cover_image_url.
476 pub gallery: Vec<super::CarouselFrame>,
477 /// Pre-rendered primitive-layer CSS for the parent project's chosen theme
478 /// (items inherit it), injected into `<head>` (Tier 0). See `crate::theming`.
479 pub theme_css: &'static str,
480 }
481
482 /// Library (consumption) view for download / bundle / other items.
483 /// Audio + video items currently render this too; dedicated templates land in
484 /// Phases 2–3.
485 #[derive(Template)]
486 #[template(path = "pages/library_downloads.html")]
487 #[allow(dead_code)]
488 pub struct LibraryDownloadsTemplate {
489 pub csrf_token: CsrfTokenOption,
490 pub session_user: Option<SessionUser>,
491 pub item: Item,
492 pub creator_username: String,
493 pub project_title: String,
494 pub project_slug: String,
495 pub host_url: Arc<str>,
496 pub versions: Vec<Version>,
497 /// Child items if this is a bundle; otherwise empty. Children get `/l/` links
498 /// because the viewer (by being on this page) has access via the bundle.
499 pub bundle_items: Vec<Item>,
500 pub sections: Vec<ItemSection>,
501 pub discussion_url: Option<String>,
502 pub discussion_count: Option<i64>,
503 pub is_owner: bool,
504 }
505
506 /// 403 page shown when a viewer hits /l/{id} but lacks access.
507 #[derive(Template)]
508 #[template(path = "pages/library_locked.html")]
509 #[allow(dead_code)]
510 pub struct LibraryLockedTemplate {
511 pub csrf_token: CsrfTokenOption,
512 pub session_user: Option<SessionUser>,
513 pub item: Item,
514 pub creator_username: String,
515 pub host_url: Arc<str>,
516 /// For unlisted items: bundles that contain this item.
517 pub containing_bundles: Vec<Item>,
518 pub is_logged_in: bool,
519 }
520
521 /// Library (consumption) view for text items, full article body, discussion.
522 #[derive(Template)]
523 #[template(path = "pages/library_text.html")]
524 #[allow(dead_code)]
525 pub struct LibraryTextTemplate {
526 pub csrf_token: CsrfTokenOption,
527 pub session_user: Option<SessionUser>,
528 pub item: Item,
529 pub creator_username: String,
530 pub creator_display_name: Option<String>,
531 pub creator_avatar_initials: String,
532 pub project_title: String,
533 pub project_slug: String,
534 /// Fully rendered article body HTML.
535 pub body_html: Option<String>,
536 pub reading_time: Option<String>,
537 pub host_url: Arc<str>,
538 pub discussion_url: Option<String>,
539 pub discussion_count: Option<i64>,
540 pub is_owner: bool,
541 }
542
543 /// Blog/article reader view.
544 #[derive(Template)]
545 #[template(path = "pages/text_reader.html")]
546 #[allow(dead_code)] // Fields used by Askama template
547 pub struct TextReaderTemplate {
548 pub csrf_token: CsrfTokenOption,
549 pub session_user: Option<SessionUser>,
550 pub item: Item,
551 pub creator_username: String,
552 pub creator_display_name: Option<String>,
553 /// First-letter initials for the avatar circle (e.g. "JD" for "Jane Doe").
554 pub creator_avatar_initials: String,
555 pub project_title: String,
556 pub project_slug: String,
557 /// Whether the item has a zero price (free content, no purchase required).
558 pub is_free: bool,
559 /// Whether the current user already has this item in their library.
560 pub in_library: bool,
561 /// Drives the CTA swap: true → "Read in library", false → Buy/PWYW/Add-to-Library.
562 pub has_access: bool,
563 pub reading_time: Option<String>,
564 /// Short plain-text preview of the article body, shown on the store page.
565 pub excerpt: Option<String>,
566 /// Base URL for OG meta tags.
567 pub host_url: Arc<str>,
568 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
569 pub discussion_url: Option<String>,
570 /// Number of posts in the linked discussion thread.
571 pub discussion_count: Option<i64>,
572 }
573
574 /// Library (consumption) view for audio items, full player, chapters,
575 /// description, optional source-file downloads, discussion.
576 #[derive(Template)]
577 #[template(path = "pages/library_audio.html")]
578 #[allow(dead_code)]
579 pub struct LibraryAudioTemplate {
580 pub csrf_token: CsrfTokenOption,
581 pub session_user: Option<SessionUser>,
582 pub item: Item,
583 pub creator_username: String,
584 pub creator_display_name: Option<String>,
585 pub creator_avatar_initials: String,
586 pub project_title: Option<String>,
587 pub project_slug: String,
588 pub audio_url: Option<String>,
589 pub chapters: Vec<Chapter>,
590 pub segments_json: String,
591 /// Source-file downloads if the creator offers them alongside the stream.
592 pub versions: Vec<Version>,
593 pub host_url: Arc<str>,
594 pub discussion_url: Option<String>,
595 pub discussion_count: Option<i64>,
596 pub is_owner: bool,
597 }
598
599 /// Audio streaming player view.
600 #[derive(Template)]
601 #[template(path = "pages/audio_player.html")]
602 pub struct AudioPlayerTemplate {
603 pub csrf_token: CsrfTokenOption,
604 pub session_user: Option<SessionUser>,
605 pub item: Item,
606 pub creator_username: String,
607 pub creator_display_name: Option<String>,
608 /// First-letter initials for the avatar circle.
609 pub creator_avatar_initials: String,
610 pub project_title: Option<String>,
611 pub project_slug: String,
612 /// Whether the item has a zero price.
613 pub is_free: bool,
614 /// Whether the current user already has this item in their library.
615 pub in_library: bool,
616 /// Drives the CTA swap: true → "View in library", false → Buy/PWYW or Add-to-Library.
617 pub has_access: bool,
618 /// Base URL for OG meta tags.
619 pub host_url: Arc<str>,
620 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
621 pub discussion_url: Option<String>,
622 /// Number of posts in the linked discussion thread.
623 pub discussion_count: Option<i64>,
624 }
625
626 /// Library (consumption) view for video items, full player, chapters,
627 /// description, optional source-file downloads, discussion.
628 #[derive(Template)]
629 #[template(path = "pages/library_video.html")]
630 #[allow(dead_code)]
631 pub struct LibraryVideoTemplate {
632 pub csrf_token: CsrfTokenOption,
633 pub session_user: Option<SessionUser>,
634 pub item: Item,
635 pub creator_username: String,
636 pub creator_display_name: Option<String>,
637 pub creator_avatar_initials: String,
638 pub project_title: Option<String>,
639 pub project_slug: String,
640 pub video_url: Option<String>,
641 pub chapters: Vec<Chapter>,
642 pub segments_json: String,
643 pub versions: Vec<Version>,
644 pub host_url: Arc<str>,
645 pub discussion_url: Option<String>,
646 pub discussion_count: Option<i64>,
647 pub is_owner: bool,
648 }
649
650 /// Video player page with custom controls, insertions, chapters.
651 #[derive(Template)]
652 #[template(path = "pages/video_player.html")]
653 pub struct VideoPlayerTemplate {
654 pub csrf_token: CsrfTokenOption,
655 pub session_user: Option<SessionUser>,
656 pub item: Item,
657 pub creator_username: String,
658 pub creator_display_name: Option<String>,
659 pub creator_avatar_initials: String,
660 pub project_title: Option<String>,
661 pub project_slug: String,
662 pub is_free: bool,
663 pub in_library: bool,
664 pub has_access: bool,
665 pub host_url: Arc<str>,
666 pub discussion_url: Option<String>,
667 pub discussion_count: Option<i64>,
668 }
669
670 /// Browse/discover page with filtering and pagination.
671 #[derive(Template)]
672 #[template(path = "pages/discover.html")]
673 pub struct DiscoverTemplate {
674 pub csrf_token: CsrfTokenOption,
675 pub session_user: Option<SessionUser>,
676 pub items: Vec<DiscoverItem>,
677 pub projects: Vec<DiscoverProject>,
678 /// Active browse mode: `"items"` or `"projects"`.
679 pub mode: String,
680 pub total_items: u32,
681 pub current_page: u32,
682 pub total_pages: u32,
683 pub search_query: String,
684 /// A search term was applied. Drives the empty-state copy.
685 ///
686 /// Not `!search_query.is_empty()`: that is the raw `?q=`, and a
687 /// whitespace-only term is browsing as far as the query is concerned.
688 pub is_search: bool,
689 /// The rendered count line ("247 results", "1 item"). Built once in
690 /// `results_count_label` because the page and the out-of-band partial both
691 /// render `#total-count`; a difference between them would show up as the
692 /// text changing on the first HTMX swap.
693 pub count_label: String,
694 /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`).
695 pub sort_by: String,
696 /// Page numbers to render in the pagination bar.
697 pub pagination_range: Vec<u32>,
698 pub showing_start: u32,
699 pub showing_end: u32,
700 /// Everything the sidebar renders. Grouped so the results partial can carry
701 /// the identical set for its out-of-band swap.
702 pub sidebar: SidebarView,
703 /// Whether the current user is authenticated (for collection save buttons in results).
704 pub is_authenticated: bool,
705 /// Always false here: the page renders the sidebar directly, so the
706 /// included results partial must not emit a second out-of-band copy.
707 pub oob_sidebar: bool,
708 }
709
710 /// Tag tree browser with breadcrumb navigation.
711 #[derive(Template)]
712 #[template(path = "pages/tag_tree.html")]
713 pub struct TagTreeTemplate {
714 pub csrf_token: CsrfTokenOption,
715 pub session_user: Option<SessionUser>,
716 pub categories: Vec<TagTreeNode>,
717 pub breadcrumbs: Vec<TagBreadcrumb>,
718 pub current_tag: Option<TagBreadcrumb>,
719 }
720
721 /// Purchase confirmation page showing fee breakdown.
722 #[derive(Template)]
723 #[template(path = "pages/purchase.html")]
724 pub struct PurchaseTemplate {
725 pub csrf_token: CsrfTokenOption,
726 pub item: Item,
727 pub creator_username: String,
728 /// The creator's currency symbol, for the amount fields the template frames
729 /// itself (the PWYW input prefix, the zero platform fee). Amounts formatted
730 /// in Rust already carry their own symbol and must not be prefixed again.
731 pub currency_symbol: &'static str,
732 /// Whether the processing-fee breakdown can be shown with real numbers.
733 /// False outside USD, where MNW does not model Stripe's local pricing.
734 pub show_fee_estimate: bool,
735 pub stripe_fee: String,
736 pub creator_receives: String,
737 /// Pre-filled promo code from `?code=` query parameter.
738 pub promo_code: String,
739 /// Whether PWYW pricing is enabled for this item.
740 pub pwyw_enabled: bool,
741 /// Minimum price in cents when PWYW is enabled.
742 pub pwyw_min_cents: i32,
743 /// Formatted suggested price in dollars (e.g. "9.99").
744 pub suggested_price: String,
745 /// Formatted minimum price in dollars (e.g. "1.00").
746 pub pwyw_min_dollars: String,
747 /// Whether the creator has Stripe Tax enabled.
748 pub stripe_tax_enabled: bool,
749 /// Whether the current visitor is logged in (show guest checkout if not).
750 pub is_logged_in: bool,
751 /// If the buyer has an in-progress (pending) checkout for this item,
752 /// the relative time it was started (e.g. "5 minutes ago"). Empty
753 /// string means no pending checkout.
754 pub pending_started: String,
755 }
756
757 /// Minimal direct purchase page, no navigation, for link-in-bio sharing.
758 #[derive(Template)]
759 #[template(path = "pages/buy.html")]
760 pub struct BuyPageTemplate {
761 pub item: Item,
762 pub creator_username: String,
763 /// The creator's currency symbol, for the PWYW input prefix.
764 pub currency_symbol: &'static str,
765 pub creator_display_name: Option<String>,
766 pub pwyw_enabled: bool,
767 pub pwyw_min_dollars: String,
768 pub suggested_price: String,
769 pub host_url: Arc<str>,
770 }
771
772 /// Public page: Stripe Connect disclaimer and terms before onboarding.
773 #[derive(Template)]
774 #[template(path = "pages/stripe_disclaimer.html")]
775 pub struct StripeConnectDisclaimerTemplate {
776 pub csrf_token: CsrfTokenOption,
777 }
778
779 // Fan+
780
781 /// Fan+ subscription page: marketing, subscribe, or manage.
782 #[derive(Template)]
783 #[template(path = "pages/fan_plus.html")]
784 pub struct FanPlusTemplate {
785 pub csrf_token: CsrfTokenOption,
786 pub session_user: Option<SessionUser>,
787 /// Whether the user has an active Fan+ subscription.
788 pub is_subscribed: bool,
789 /// Current billing period end (if subscribed).
790 pub period_end: Option<String>,
791 /// Whether a `?subscribed=true` query was present (just subscribed).
792 pub just_subscribed: bool,
793 }
794
795 // Blog Pages
796
797 /// Public blog index for a project.
798 #[derive(Template)]
799 #[template(path = "pages/project_blog.html")]
800 pub struct ProjectBlogTemplate {
801 pub csrf_token: CsrfTokenOption,
802 pub session_user: Option<SessionUser>,
803 pub project: Project,
804 pub creator_username: String,
805 pub project_slug: String,
806 pub posts: Vec<BlogPostSummary>,
807 }
808
809 /// Public blog post reader.
810 #[derive(Template)]
811 #[template(path = "pages/blog_post.html")]
812 pub struct BlogPostTemplate {
813 pub csrf_token: CsrfTokenOption,
814 pub session_user: Option<SessionUser>,
815 pub title: String,
816 /// Title escaped for JSON string embedding (JSON-LD).
817 pub title_json: String,
818 pub body_html: String,
819 pub published_at: String,
820 pub creator_username: String,
821 pub creator_display_name: Option<String>,
822 pub creator_avatar_initials: String,
823 pub project_title: String,
824 /// Project title escaped for JSON string embedding (JSON-LD).
825 pub project_title_json: String,
826 pub project_slug: String,
827 /// URL-safe slug for this blog post.
828 pub post_slug: String,
829 /// Base URL for OG meta tags.
830 pub host_url: Arc<str>,
831 /// Project cover image URL (fallback for og:image when blog post has no specific image).
832 pub project_cover_image_url: Option<String>,
833 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
834 pub discussion_url: Option<String>,
835 /// Number of posts in the linked discussion thread.
836 pub discussion_count: Option<i64>,
837 }
838
839 // Documentation Pages
840
841 /// Individual documentation page.
842 #[derive(Template)]
843 #[template(path = "pages/doc.html")]
844 pub struct DocTemplate {
845 pub csrf_token: CsrfTokenOption,
846 pub session_user: Option<SessionUser>,
847 pub title: String,
848 pub section: String,
849 pub content: String,
850 /// Pages that link to this one ("what links here"), in docs-index order.
851 /// Empty when nothing links here, in which case the template omits the
852 /// section entirely.
853 pub backlinks: Vec<DocSectionEntry>,
854 }
855
856 /// Entry in a doc section for the index page.
857 pub struct DocSectionEntry {
858 pub title: String,
859 pub slug: String,
860 }
861
862 /// A collapsible subcategory within a doc section.
863 pub struct DocSubsection {
864 pub label: String,
865 pub entries: Vec<DocSectionEntry>,
866 }
867
868 /// A group of doc entries under a section heading.
869 pub struct DocSection {
870 pub name: String,
871 pub entries: Vec<DocSectionEntry>,
872 pub subsections: Vec<DocSubsection>,
873 }
874
875 /// Documentation index page listing all docs by section.
876 #[derive(Template)]
877 #[template(path = "pages/doc_index.html")]
878 pub struct DocIndexTemplate {
879 pub csrf_token: CsrfTokenOption,
880 pub session_user: Option<SessionUser>,
881 pub sections: Vec<DocSection>,
882 }
883
884 /// Platform economics + runway disclosure page. Renders at `/economics`
885 /// (the retired markdown page's `/docs/economics` URL 301s here). See the
886 /// doc comment on `templates/pages/economics.html` for the maintenance
887 /// contract.
888 #[derive(Template)]
889 #[template(path = "pages/economics.html")]
890 #[allow(dead_code)] // Fields used by Askama template
891 pub struct EconomicsTemplate {
892 pub csrf_token: CsrfTokenOption,
893 pub session_user: Option<SessionUser>,
894 /// `quarters` + `last_updated_iso` from `[runway]` in
895 /// `assumptions.toml`. Operator-edited; refreshed quarterly.
896 pub runway_config: crate::tier_prices::RunwayConfig,
897 /// Live count of `status='active'` creator subscriptions. Pulled at
898 /// request time so the page never lies about how many seats are
899 /// revenue-bearing right now.
900 pub paying_creators: i64,
901 /// Live count of `status='trialing'` plus canceled-with-grace
902 /// creators. Disclosed only when non-zero (the template hides the
903 /// bullet otherwise so a quiet platform doesn't show "0 in trial").
904 pub trialing_or_grace: i64,
905 }
906
907 /// Use cases page showcasing creator types.
908 #[derive(Template)]
909 #[template(path = "pages/use_cases.html")]
910 pub struct UseCasesTemplate {
911 pub csrf_token: CsrfTokenOption,
912 pub session_user: Option<SessionUser>,
913 pub tier_prices: crate::tier_prices::TierPrices,
914 }
915
916 /// Team page listing the founder, residents, and fellows.
917 #[derive(Template)]
918 #[template(path = "pages/team.html")]
919 pub struct TeamTemplate {
920 pub csrf_token: CsrfTokenOption,
921 pub session_user: Option<SessionUser>,
922 }
923
924 // Creator Invite System
925
926 /// Public page: creator signup, tier pricing, and the active-creator count.
927 #[derive(Template)]
928 #[template(path = "pages/creators.html")]
929 pub struct CreatorsTemplate {
930 pub csrf_token: CsrfTokenOption,
931 pub session_user: Option<SessionUser>,
932 pub total_creators: u32,
933 pub is_creator: bool,
934 pub tier_prices: crate::tier_prices::TierPrices,
935 }
936
937 // Email & Account
938
939 /// Public page: email action result (verification, unsubscribe, etc.).
940 #[derive(Template)]
941 #[template(path = "pages/email_result.html")]
942 pub struct EmailResultTemplate {
943 pub csrf_token: CsrfTokenOption,
944 pub title: String,
945 pub message: String,
946 pub link_url: String,
947 pub link_text: String,
948 }
949
950 /// Public page: email preferences, reached from a signed link in any email.
951 ///
952 /// Sessionless on purpose. Somebody who wants out of our email should not have
953 /// to remember a password first, and the signed token is the authorisation.
954 #[derive(Template)]
955 #[template(path = "pages/email_preferences.html")]
956 pub struct EmailPreferencesTemplate {
957 pub csrf_token: CsrfTokenOption,
958 /// The signed token, echoed into each form so every action on the page
959 /// carries the same authorisation the page itself did.
960 pub token: String,
961 pub signature: String,
962 /// The subscription the link was minted for, highlighted so the page
963 /// answers "this one" before it offers the rest.
964 pub origin_subscription: crate::db::ListSubscriptionId,
965 pub subscriptions: Vec<crate::db::lists::SubscriptionRow>,
966 }
967
968 /// Purchase receipt page.
969 #[derive(Template)]
970 #[template(path = "pages/receipt.html")]
971 pub struct ReceiptTemplate {
972 pub csrf_token: CsrfTokenOption,
973 /// The symbol of the currency this sale was denominated in, for the zero
974 /// platform-fee line. `amount` already carries its own.
975 pub currency_symbol: &'static str,
976 /// What the buyer was actually charged, pre-formatted, when Stripe
977 /// converted at checkout. Empty when there was no conversion.
978 ///
979 /// This is the exact figure the disclosure at checkout could only give a
980 /// range for: Stripe reports it after payment and never breaks out the fee
981 /// inside it, so the receipt is the first and only place a buyer can
982 /// reconcile against their statement.
983 pub presented_amount: String,
984 pub session_user: Option<crate::auth::SessionUser>,
985 pub transaction_id: String,
986 pub item_id: String,
987 pub item_title: String,
988 pub seller_username: String,
989 pub amount: String,
990 pub is_free: bool,
991 pub status: String,
992 pub date: String,
993 }
994
995 /// Confirmation page shown before account deletion (GET step).
996 #[derive(Template)]
997 #[template(path = "pages/confirm_delete.html")]
998 pub struct ConfirmDeleteTemplate {
999 pub csrf_token: CsrfTokenOption,
1000 pub user: String,
1001 pub expires: String,
1002 pub sig: String,
1003 }
1004
1005 /// The acknowledgement page an alert's link lands on.
1006 ///
1007 /// Sessionless: whoever holds the link sees it. That is why it carries only
1008 /// what the alert is about and nothing identifying the account, and why the
1009 /// button records an acknowledgement rather than changing anything.
1010 #[derive(Template)]
1011 #[template(path = "pages/acknowledge.html")]
1012 pub struct AcknowledgeTemplate {
1013 /// Always `None`. The route is CSRF-skip because holding the link is the
1014 /// authorisation, and the page is reached without a session, so there is no
1015 /// token to render. `base.html` requires the field.
1016 pub csrf_token: CsrfTokenOption,
1017 pub title: String,
1018 /// Body copy already split into paragraphs, because the source is a mail
1019 /// body with blank lines in it and the template should not be parsing prose.
1020 pub detail: Vec<String>,
1021 pub token: String,
1022 pub acknowledged: bool,
1023 }
1024
1025 /// Public page: confirmation that account has been deleted.
1026 #[derive(Template)]
1027 #[template(path = "pages/account-deleted.html")]
1028 pub struct AccountDeletedTemplate {
1029 pub csrf_token: CsrfTokenOption,
1030 }
1031
1032 #[cfg(test)]
1033 mod conversion_visibility_tests {
1034 use super::cart_conversion_applies;
1035 use crate::currency::SettlementCurrency;
1036
1037 #[test]
1038 fn an_unknown_buyer_currency_always_gets_the_choice() {
1039 // The common case: a fan with no Stripe account. We know nothing about
1040 // the card they will pay with, so we must not decide for them.
1041 for seller in SettlementCurrency::ALL {
1042 assert!(cart_conversion_applies(None, seller), "{seller}");
1043 }
1044 }
1045
1046 #[test]
1047 fn a_known_match_hides_the_choice() {
1048 assert!(!cart_conversion_applies(
1049 Some(SettlementCurrency::Gbp),
1050 SettlementCurrency::Gbp
1051 ));
1052 }
1053
1054 #[test]
1055 fn a_known_mismatch_shows_the_choice() {
1056 assert!(cart_conversion_applies(
1057 Some(SettlementCurrency::Gbp),
1058 SettlementCurrency::Usd
1059 ));
1060 }
1061 }
1062