Skip to main content

max / makenotwork

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