Skip to main content

max / makenotwork

41.0 KB · 1109 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 (UX-S1, Run #23).
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 /// Available subscription tiers (for subscription-model projects).
426 pub subscription_tiers: Vec<SubscriptionTier>,
427 /// Base URL for OG meta tags.
428 pub host_url: Arc<str>,
429 }
430
431 /// Public item detail page.
432 #[derive(Template)]
433 #[template(path = "pages/item.html")]
434 #[allow(dead_code)] // Fields used by Askama template
435 pub struct ItemTemplate {
436 pub csrf_token: CsrfTokenOption,
437 pub session_user: Option<SessionUser>,
438 pub item: Item,
439 pub creator_username: String,
440 /// Uppercase ISO code for the JSON-LD `priceCurrency`. Structured data is
441 /// read by machines that will not second-guess it, so a hardcoded USD here
442 /// would misprice a non-USD creator's work in search results.
443 pub price_currency: &'static str,
444 pub project_title: String,
445 pub project_slug: String,
446 /// Base URL for OG meta tags.
447 pub host_url: Arc<str>,
448 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
449 pub discussion_url: Option<String>,
450 /// Number of posts in the linked discussion thread.
451 pub discussion_count: Option<i64>,
452 /// Project cover image URL (fallback for og:image when item has no cover).
453 pub project_cover_image_url: Option<String>,
454 /// Child items for bundle-type items (empty for non-bundles).
455 pub bundle_items: Vec<Item>,
456 /// Bundles containing this item (for unlisted items, to show "Available in" links).
457 pub containing_bundles: Vec<Item>,
458 /// Tabbed content sections (e.g. Features, Installation, Specs).
459 pub sections: Vec<ItemSection>,
460 /// Whether the current user is the item's creator (for dashboard links).
461 pub is_owner: bool,
462 /// Whether the current user has wishlisted this item.
463 pub is_wishlisted: bool,
464 /// Whether the current user has this item in their cart.
465 pub in_cart: bool,
466 /// How many of the current user's collections contain this item.
467 pub collection_count: u32,
468 /// Whether the current user can consume this item (purchased, free, subscribed, creator, bundle).
469 /// Drives the store-page CTA swap: true → "View in library", false → Buy/PWYW.
470 pub has_access: bool,
471 /// Ordered gallery images rendered through the shared carousel widget
472 /// (empty → the carousel section is suppressed). Additive to cover_image_url.
473 pub gallery: Vec<super::CarouselFrame>,
474 /// Pre-rendered primitive-layer CSS for the parent project's chosen theme
475 /// (items inherit it), injected into `<head>` (Tier 0). See `crate::theming`.
476 pub theme_css: &'static str,
477 }
478
479 /// Library (consumption) view for download / bundle / other items.
480 /// Audio + video items currently render this too; dedicated templates land in
481 /// Phases 2–3.
482 #[derive(Template)]
483 #[template(path = "pages/library_downloads.html")]
484 #[allow(dead_code)]
485 pub struct LibraryDownloadsTemplate {
486 pub csrf_token: CsrfTokenOption,
487 pub session_user: Option<SessionUser>,
488 pub item: Item,
489 pub creator_username: String,
490 pub project_title: String,
491 pub project_slug: String,
492 pub host_url: Arc<str>,
493 pub versions: Vec<Version>,
494 /// Child items if this is a bundle; otherwise empty. Children get `/l/` links
495 /// because the viewer (by being on this page) has access via the bundle.
496 pub bundle_items: Vec<Item>,
497 pub sections: Vec<ItemSection>,
498 pub discussion_url: Option<String>,
499 pub discussion_count: Option<i64>,
500 pub is_owner: bool,
501 }
502
503 /// 403 page shown when a viewer hits /l/{id} but lacks access.
504 #[derive(Template)]
505 #[template(path = "pages/library_locked.html")]
506 #[allow(dead_code)]
507 pub struct LibraryLockedTemplate {
508 pub csrf_token: CsrfTokenOption,
509 pub session_user: Option<SessionUser>,
510 pub item: Item,
511 pub creator_username: String,
512 pub host_url: Arc<str>,
513 /// For unlisted items: bundles that contain this item.
514 pub containing_bundles: Vec<Item>,
515 pub is_logged_in: bool,
516 }
517
518 /// Library (consumption) view for text items, full article body, discussion.
519 #[derive(Template)]
520 #[template(path = "pages/library_text.html")]
521 #[allow(dead_code)]
522 pub struct LibraryTextTemplate {
523 pub csrf_token: CsrfTokenOption,
524 pub session_user: Option<SessionUser>,
525 pub item: Item,
526 pub creator_username: String,
527 pub creator_display_name: Option<String>,
528 pub creator_avatar_initials: String,
529 pub project_title: String,
530 pub project_slug: String,
531 /// Fully rendered article body HTML.
532 pub body_html: Option<String>,
533 pub reading_time: Option<String>,
534 pub host_url: Arc<str>,
535 pub discussion_url: Option<String>,
536 pub discussion_count: Option<i64>,
537 pub is_owner: bool,
538 }
539
540 /// Blog/article reader view.
541 #[derive(Template)]
542 #[template(path = "pages/text_reader.html")]
543 #[allow(dead_code)] // Fields used by Askama template
544 pub struct TextReaderTemplate {
545 pub csrf_token: CsrfTokenOption,
546 pub session_user: Option<SessionUser>,
547 pub item: Item,
548 pub creator_username: String,
549 pub creator_display_name: Option<String>,
550 /// First-letter initials for the avatar circle (e.g. "JD" for "Jane Doe").
551 pub creator_avatar_initials: String,
552 pub project_title: String,
553 pub project_slug: String,
554 /// Whether the item has a zero price (free content, no purchase required).
555 pub is_free: bool,
556 /// Whether the current user already has this item in their library.
557 pub in_library: bool,
558 /// Drives the CTA swap: true → "Read in library", false → Buy/PWYW/Add-to-Library.
559 pub has_access: bool,
560 pub reading_time: Option<String>,
561 /// Short plain-text preview of the article body, shown on the store page.
562 pub excerpt: Option<String>,
563 /// Base URL for OG meta tags.
564 pub host_url: Arc<str>,
565 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
566 pub discussion_url: Option<String>,
567 /// Number of posts in the linked discussion thread.
568 pub discussion_count: Option<i64>,
569 }
570
571 /// Library (consumption) view for audio items, full player, chapters,
572 /// description, optional source-file downloads, discussion.
573 #[derive(Template)]
574 #[template(path = "pages/library_audio.html")]
575 #[allow(dead_code)]
576 pub struct LibraryAudioTemplate {
577 pub csrf_token: CsrfTokenOption,
578 pub session_user: Option<SessionUser>,
579 pub item: Item,
580 pub creator_username: String,
581 pub creator_display_name: Option<String>,
582 pub creator_avatar_initials: String,
583 pub project_title: Option<String>,
584 pub project_slug: String,
585 pub audio_url: Option<String>,
586 pub chapters: Vec<Chapter>,
587 pub segments_json: String,
588 /// Source-file downloads if the creator offers them alongside the stream.
589 pub versions: Vec<Version>,
590 pub host_url: Arc<str>,
591 pub discussion_url: Option<String>,
592 pub discussion_count: Option<i64>,
593 pub is_owner: bool,
594 }
595
596 /// Audio streaming player view.
597 #[derive(Template)]
598 #[template(path = "pages/audio_player.html")]
599 pub struct AudioPlayerTemplate {
600 pub csrf_token: CsrfTokenOption,
601 pub session_user: Option<SessionUser>,
602 pub item: Item,
603 pub creator_username: String,
604 pub creator_display_name: Option<String>,
605 /// First-letter initials for the avatar circle.
606 pub creator_avatar_initials: String,
607 pub project_title: Option<String>,
608 pub project_slug: String,
609 /// Whether the item has a zero price.
610 pub is_free: bool,
611 /// Whether the current user already has this item in their library.
612 pub in_library: bool,
613 /// Drives the CTA swap: true → "View in library", false → Buy/PWYW or Add-to-Library.
614 pub has_access: bool,
615 /// Base URL for OG meta tags.
616 pub host_url: Arc<str>,
617 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
618 pub discussion_url: Option<String>,
619 /// Number of posts in the linked discussion thread.
620 pub discussion_count: Option<i64>,
621 }
622
623 /// Library (consumption) view for video items, full player, chapters,
624 /// description, optional source-file downloads, discussion.
625 #[derive(Template)]
626 #[template(path = "pages/library_video.html")]
627 #[allow(dead_code)]
628 pub struct LibraryVideoTemplate {
629 pub csrf_token: CsrfTokenOption,
630 pub session_user: Option<SessionUser>,
631 pub item: Item,
632 pub creator_username: String,
633 pub creator_display_name: Option<String>,
634 pub creator_avatar_initials: String,
635 pub project_title: Option<String>,
636 pub project_slug: String,
637 pub video_url: Option<String>,
638 pub chapters: Vec<Chapter>,
639 pub segments_json: String,
640 pub versions: Vec<Version>,
641 pub host_url: Arc<str>,
642 pub discussion_url: Option<String>,
643 pub discussion_count: Option<i64>,
644 pub is_owner: bool,
645 }
646
647 /// Video player page with custom controls, insertions, chapters.
648 #[derive(Template)]
649 #[template(path = "pages/video_player.html")]
650 pub struct VideoPlayerTemplate {
651 pub csrf_token: CsrfTokenOption,
652 pub session_user: Option<SessionUser>,
653 pub item: Item,
654 pub creator_username: String,
655 pub creator_display_name: Option<String>,
656 pub creator_avatar_initials: String,
657 pub project_title: Option<String>,
658 pub project_slug: String,
659 pub is_free: bool,
660 pub in_library: bool,
661 pub has_access: bool,
662 pub host_url: Arc<str>,
663 pub discussion_url: Option<String>,
664 pub discussion_count: Option<i64>,
665 }
666
667 /// Browse/discover page with filtering and pagination.
668 #[derive(Template)]
669 #[template(path = "pages/discover.html")]
670 pub struct DiscoverTemplate {
671 pub csrf_token: CsrfTokenOption,
672 pub session_user: Option<SessionUser>,
673 pub items: Vec<DiscoverItem>,
674 pub projects: Vec<DiscoverProject>,
675 /// Active browse mode: `"items"` or `"projects"`.
676 pub mode: String,
677 pub total_items: u32,
678 pub current_page: u32,
679 pub total_pages: u32,
680 pub search_query: String,
681 /// A search term was applied. Drives the empty-state copy.
682 ///
683 /// Not `!search_query.is_empty()`: that is the raw `?q=`, and a
684 /// whitespace-only term is browsing as far as the query is concerned.
685 pub is_search: bool,
686 /// The rendered count line ("247 results", "1 item"). Built once in
687 /// `results_count_label` because the page and the out-of-band partial both
688 /// render `#total-count`; a difference between them would show up as the
689 /// text changing on the first HTMX swap.
690 pub count_label: String,
691 /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`).
692 pub sort_by: String,
693 /// Page numbers to render in the pagination bar.
694 pub pagination_range: Vec<u32>,
695 pub showing_start: u32,
696 pub showing_end: u32,
697 /// Everything the sidebar renders. Grouped so the results partial can carry
698 /// the identical set for its out-of-band swap.
699 pub sidebar: SidebarView,
700 /// Whether the current user is authenticated (for collection save buttons in results).
701 pub is_authenticated: bool,
702 /// Always false here: the page renders the sidebar directly, so the
703 /// included results partial must not emit a second out-of-band copy.
704 pub oob_sidebar: bool,
705 }
706
707 /// Tag tree browser with breadcrumb navigation.
708 #[derive(Template)]
709 #[template(path = "pages/tag_tree.html")]
710 pub struct TagTreeTemplate {
711 pub csrf_token: CsrfTokenOption,
712 pub session_user: Option<SessionUser>,
713 pub categories: Vec<TagTreeNode>,
714 pub breadcrumbs: Vec<TagBreadcrumb>,
715 pub current_tag: Option<TagBreadcrumb>,
716 }
717
718 /// Purchase confirmation page showing fee breakdown.
719 #[derive(Template)]
720 #[template(path = "pages/purchase.html")]
721 pub struct PurchaseTemplate {
722 pub csrf_token: CsrfTokenOption,
723 pub item: Item,
724 pub creator_username: String,
725 /// The creator's currency symbol, for the amount fields the template frames
726 /// itself (the PWYW input prefix, the zero platform fee). Amounts formatted
727 /// in Rust already carry their own symbol and must not be prefixed again.
728 pub currency_symbol: &'static str,
729 /// Whether the processing-fee breakdown can be shown with real numbers.
730 /// False outside USD, where MNW does not model Stripe's local pricing.
731 pub show_fee_estimate: bool,
732 pub stripe_fee: String,
733 pub creator_receives: String,
734 /// Pre-filled promo code from `?code=` query parameter.
735 pub promo_code: String,
736 /// Whether PWYW pricing is enabled for this item.
737 pub pwyw_enabled: bool,
738 /// Minimum price in cents when PWYW is enabled.
739 pub pwyw_min_cents: i32,
740 /// Formatted suggested price in dollars (e.g. "9.99").
741 pub suggested_price: String,
742 /// Formatted minimum price in dollars (e.g. "1.00").
743 pub pwyw_min_dollars: String,
744 /// Whether the creator has Stripe Tax enabled.
745 pub stripe_tax_enabled: bool,
746 /// Whether the current visitor is logged in (show guest checkout if not).
747 pub is_logged_in: bool,
748 /// If the buyer has an in-progress (pending) checkout for this item,
749 /// the relative time it was started (e.g. "5 minutes ago"). Empty
750 /// string means no pending checkout.
751 pub pending_started: String,
752 }
753
754 /// Minimal direct purchase page, no navigation, for link-in-bio sharing.
755 #[derive(Template)]
756 #[template(path = "pages/buy.html")]
757 pub struct BuyPageTemplate {
758 pub item: Item,
759 pub creator_username: String,
760 /// The creator's currency symbol, for the PWYW input prefix.
761 pub currency_symbol: &'static str,
762 pub creator_display_name: Option<String>,
763 pub pwyw_enabled: bool,
764 pub pwyw_min_dollars: String,
765 pub suggested_price: String,
766 pub host_url: Arc<str>,
767 }
768
769 /// Feed page showing items from followed users, projects, and tags.
770 #[derive(Template)]
771 #[template(path = "pages/feed.html")]
772 pub struct FeedTemplate {
773 pub csrf_token: CsrfTokenOption,
774 pub session_user: Option<SessionUser>,
775 pub items: Vec<DiscoverItem>,
776 pub total_items: u32,
777 pub current_page: u32,
778 pub total_pages: u32,
779 pub pagination_range: Vec<u32>,
780 pub showing_start: u32,
781 pub showing_end: u32,
782 }
783
784 /// Public page: Stripe Connect disclaimer and terms before onboarding.
785 #[derive(Template)]
786 #[template(path = "pages/stripe_disclaimer.html")]
787 pub struct StripeConnectDisclaimerTemplate {
788 pub csrf_token: CsrfTokenOption,
789 }
790
791 // Fan+
792
793 /// Fan+ subscription page: marketing, subscribe, or manage.
794 #[derive(Template)]
795 #[template(path = "pages/fan_plus.html")]
796 pub struct FanPlusTemplate {
797 pub csrf_token: CsrfTokenOption,
798 pub session_user: Option<SessionUser>,
799 /// Whether the user has an active Fan+ subscription.
800 pub is_subscribed: bool,
801 /// Current billing period end (if subscribed).
802 pub period_end: Option<String>,
803 /// Whether a `?subscribed=true` query was present (just subscribed).
804 pub just_subscribed: bool,
805 }
806
807 // Blog Pages
808
809 /// Public blog index for a project.
810 #[derive(Template)]
811 #[template(path = "pages/project_blog.html")]
812 pub struct ProjectBlogTemplate {
813 pub csrf_token: CsrfTokenOption,
814 pub session_user: Option<SessionUser>,
815 pub project: Project,
816 pub creator_username: String,
817 pub project_slug: String,
818 pub posts: Vec<BlogPostSummary>,
819 }
820
821 /// Public blog post reader.
822 #[derive(Template)]
823 #[template(path = "pages/blog_post.html")]
824 pub struct BlogPostTemplate {
825 pub csrf_token: CsrfTokenOption,
826 pub session_user: Option<SessionUser>,
827 pub title: String,
828 /// Title escaped for JSON string embedding (JSON-LD).
829 pub title_json: String,
830 pub body_html: String,
831 pub published_at: String,
832 pub creator_username: String,
833 pub creator_display_name: Option<String>,
834 pub creator_avatar_initials: String,
835 pub project_title: String,
836 /// Project title escaped for JSON string embedding (JSON-LD).
837 pub project_title_json: String,
838 pub project_slug: String,
839 /// URL-safe slug for this blog post.
840 pub post_slug: String,
841 /// Base URL for OG meta tags.
842 pub host_url: Arc<str>,
843 /// Project cover image URL (fallback for og:image when blog post has no specific image).
844 pub project_cover_image_url: Option<String>,
845 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
846 pub discussion_url: Option<String>,
847 /// Number of posts in the linked discussion thread.
848 pub discussion_count: Option<i64>,
849 }
850
851 // Documentation Pages
852
853 /// Individual documentation page.
854 #[derive(Template)]
855 #[template(path = "pages/doc.html")]
856 pub struct DocTemplate {
857 pub csrf_token: CsrfTokenOption,
858 pub session_user: Option<SessionUser>,
859 pub title: String,
860 pub section: String,
861 pub content: String,
862 /// Pages that link to this one ("what links here"), in docs-index order.
863 /// Empty when nothing links here, in which case the template omits the
864 /// section entirely.
865 pub backlinks: Vec<DocSectionEntry>,
866 }
867
868 /// Entry in a doc section for the index page.
869 pub struct DocSectionEntry {
870 pub title: String,
871 pub slug: String,
872 }
873
874 /// A collapsible subcategory within a doc section.
875 pub struct DocSubsection {
876 pub label: String,
877 pub entries: Vec<DocSectionEntry>,
878 }
879
880 /// A group of doc entries under a section heading.
881 pub struct DocSection {
882 pub name: String,
883 pub entries: Vec<DocSectionEntry>,
884 pub subsections: Vec<DocSubsection>,
885 }
886
887 /// Documentation index page listing all docs by section.
888 #[derive(Template)]
889 #[template(path = "pages/doc_index.html")]
890 pub struct DocIndexTemplate {
891 pub csrf_token: CsrfTokenOption,
892 pub session_user: Option<SessionUser>,
893 pub sections: Vec<DocSection>,
894 }
895
896 // Pricing Calculator
897
898 /// Interactive fee calculator: MNW against a platform the visitor describes.
899 /// No competitor is named or held on file; see `crate::fee_calculator`.
900 #[derive(Template)]
901 #[template(path = "pages/pricing.html")]
902 pub struct PricingTemplate {
903 pub csrf_token: CsrfTokenOption,
904 pub tier_prices: crate::tier_prices::TierPrices,
905 /// Whether founder pricing is on offer right now. Gates both the banner
906 /// and the list/founder toggle: with the window shut the page renders
907 /// exactly as it did before either existed, list prices and no dead
908 /// control. Same source the landing page reads, so the two cannot
909 /// disagree about whether the offer is live.
910 pub founder_window_open: bool,
911 /// Dial positions this render used, echoed back into the input values so
912 /// a shared or reloaded URL comes back to the same scenario.
913 pub inputs: crate::fee_calculator::Inputs,
914 /// `inputs.other_pct` as whole percent for the input box (`12.6`, not
915 /// `0.126`). Preformatted so the template does no arithmetic.
916 pub other_pct_display: String,
917 pub other_per_sale_display: String,
918 /// Server-computed opening result. The page re-fetches the partial via
919 /// HTMX as the dials change.
920 pub outcome: crate::fee_calculator::Outcome,
921 }
922
923 /// HTMX partial: the recomputed calculator (verdict, both take-homes, and the
924 /// win-region bar), swapped into `/pricing` on any dial change.
925 #[derive(Template)]
926 #[template(path = "partials/fee_calculator.html")]
927 pub struct FeeCalculatorPartial {
928 pub outcome: crate::fee_calculator::Outcome,
929 }
930
931 /// Platform economics + runway disclosure page. Renders at `/economics`
932 /// (the retired markdown page's `/docs/economics` URL 301s here). See the
933 /// doc comment on `templates/pages/economics.html` for the maintenance
934 /// contract.
935 #[derive(Template)]
936 #[template(path = "pages/economics.html")]
937 #[allow(dead_code)] // Fields used by Askama template
938 pub struct EconomicsTemplate {
939 pub csrf_token: CsrfTokenOption,
940 pub session_user: Option<SessionUser>,
941 /// `quarters` + `last_updated_iso` from `[runway]` in
942 /// `assumptions.toml`. Operator-edited; refreshed quarterly.
943 pub runway_config: crate::tier_prices::RunwayConfig,
944 /// Live count of `status='active'` creator subscriptions. Pulled at
945 /// request time so the page never lies about how many seats are
946 /// revenue-bearing right now.
947 pub paying_creators: i64,
948 /// Live count of `status='trialing'` plus canceled-with-grace
949 /// creators. Disclosed only when non-zero (the template hides the
950 /// bullet otherwise so a quiet platform doesn't show "0 in trial").
951 pub trialing_or_grace: i64,
952 }
953
954 /// Use cases page showcasing creator types.
955 #[derive(Template)]
956 #[template(path = "pages/use_cases.html")]
957 pub struct UseCasesTemplate {
958 pub csrf_token: CsrfTokenOption,
959 pub session_user: Option<SessionUser>,
960 pub tier_prices: crate::tier_prices::TierPrices,
961 }
962
963 /// Team page listing the founder, residents, and fellows.
964 #[derive(Template)]
965 #[template(path = "pages/team.html")]
966 pub struct TeamTemplate {
967 pub csrf_token: CsrfTokenOption,
968 pub session_user: Option<SessionUser>,
969 }
970
971 // Creator Invite System
972
973 /// Public page: creator signup, tier pricing, and the active-creator count.
974 #[derive(Template)]
975 #[template(path = "pages/creators.html")]
976 pub struct CreatorsTemplate {
977 pub csrf_token: CsrfTokenOption,
978 pub session_user: Option<SessionUser>,
979 pub total_creators: u32,
980 pub is_creator: bool,
981 pub tier_prices: crate::tier_prices::TierPrices,
982 }
983
984 // Email & Account
985
986 /// Public page: email action result (verification, unsubscribe, etc.).
987 #[derive(Template)]
988 #[template(path = "pages/email_result.html")]
989 pub struct EmailResultTemplate {
990 pub csrf_token: CsrfTokenOption,
991 pub title: String,
992 pub message: String,
993 pub link_url: String,
994 pub link_text: String,
995 }
996
997 /// Public page: email preferences, reached from a signed link in any email.
998 ///
999 /// Sessionless on purpose. Somebody who wants out of our email should not have
1000 /// to remember a password first, and the signed token is the authorisation.
1001 #[derive(Template)]
1002 #[template(path = "pages/email_preferences.html")]
1003 pub struct EmailPreferencesTemplate {
1004 pub csrf_token: CsrfTokenOption,
1005 /// The signed token, echoed into each form so every action on the page
1006 /// carries the same authorisation the page itself did.
1007 pub token: String,
1008 pub signature: String,
1009 /// The subscription the link was minted for, highlighted so the page
1010 /// answers "this one" before it offers the rest.
1011 pub origin_subscription: crate::db::ListSubscriptionId,
1012 pub subscriptions: Vec<crate::db::lists::SubscriptionRow>,
1013 }
1014
1015 /// Purchase receipt page.
1016 #[derive(Template)]
1017 #[template(path = "pages/receipt.html")]
1018 pub struct ReceiptTemplate {
1019 pub csrf_token: CsrfTokenOption,
1020 /// The symbol of the currency this sale was denominated in, for the zero
1021 /// platform-fee line. `amount` already carries its own.
1022 pub currency_symbol: &'static str,
1023 /// What the buyer was actually charged, pre-formatted, when Stripe
1024 /// converted at checkout. Empty when there was no conversion.
1025 ///
1026 /// This is the exact figure the disclosure at checkout could only give a
1027 /// range for: Stripe reports it after payment and never breaks out the fee
1028 /// inside it, so the receipt is the first and only place a buyer can
1029 /// reconcile against their statement.
1030 pub presented_amount: String,
1031 pub session_user: Option<crate::auth::SessionUser>,
1032 pub transaction_id: String,
1033 pub item_id: String,
1034 pub item_title: String,
1035 pub seller_username: String,
1036 pub amount: String,
1037 pub is_free: bool,
1038 pub status: String,
1039 pub date: String,
1040 }
1041
1042 /// Confirmation page shown before account deletion (GET step).
1043 #[derive(Template)]
1044 #[template(path = "pages/confirm_delete.html")]
1045 pub struct ConfirmDeleteTemplate {
1046 pub csrf_token: CsrfTokenOption,
1047 pub user: String,
1048 pub expires: String,
1049 pub sig: String,
1050 }
1051
1052 /// The acknowledgement page an alert's link lands on.
1053 ///
1054 /// Sessionless: whoever holds the link sees it. That is why it carries only
1055 /// what the alert is about and nothing identifying the account, and why the
1056 /// button records an acknowledgement rather than changing anything.
1057 #[derive(Template)]
1058 #[template(path = "pages/acknowledge.html")]
1059 pub struct AcknowledgeTemplate {
1060 /// Always `None`. The route is CSRF-skip because holding the link is the
1061 /// authorisation, and the page is reached without a session, so there is no
1062 /// token to render. `base.html` requires the field.
1063 pub csrf_token: CsrfTokenOption,
1064 pub title: String,
1065 /// Body copy already split into paragraphs, because the source is a mail
1066 /// body with blank lines in it and the template should not be parsing prose.
1067 pub detail: Vec<String>,
1068 pub token: String,
1069 pub acknowledged: bool,
1070 }
1071
1072 /// Public page: confirmation that account has been deleted.
1073 #[derive(Template)]
1074 #[template(path = "pages/account-deleted.html")]
1075 pub struct AccountDeletedTemplate {
1076 pub csrf_token: CsrfTokenOption,
1077 }
1078
1079 #[cfg(test)]
1080 mod conversion_visibility_tests {
1081 use super::cart_conversion_applies;
1082 use crate::currency::SettlementCurrency;
1083
1084 #[test]
1085 fn an_unknown_buyer_currency_always_gets_the_choice() {
1086 // The common case: a fan with no Stripe account. We know nothing about
1087 // the card they will pay with, so we must not decide for them.
1088 for seller in SettlementCurrency::ALL {
1089 assert!(cart_conversion_applies(None, seller), "{seller}");
1090 }
1091 }
1092
1093 #[test]
1094 fn a_known_match_hides_the_choice() {
1095 assert!(!cart_conversion_applies(
1096 Some(SettlementCurrency::Gbp),
1097 SettlementCurrency::Gbp
1098 ));
1099 }
1100
1101 #[test]
1102 fn a_known_mismatch_shows_the_choice() {
1103 assert!(cart_conversion_applies(
1104 Some(SettlementCurrency::Gbp),
1105 SettlementCurrency::Usd
1106 ));
1107 }
1108 }
1109