//! Templates for public-facing pages: landing, auth, content, blog, discover. //! //! Git source-browser templates live in the `git` submodule and are //! re-exported flat, call sites still see `templates::GitRepoTemplate` etc. mod git; mod health; pub use git::*; pub use health::*; use std::sync::Arc; use askama::Template; use crate::auth::SessionUser; use crate::types::{ BlogPostSummary, Chapter, Collection, CollectionItem, CustomLink, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection, Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode, User, Version, }; use super::CsrfTokenOption; // Public Pages /// Sandbox info page explaining the ephemeral demo mode. #[derive(Template)] #[template(path = "pages/sandbox.html")] pub struct SandboxTemplate { pub csrf_token: CsrfTokenOption, } /// Content policy page. #[derive(Template)] #[template(path = "pages/policy.html")] pub struct PolicyTemplate { /// CSRF token injected into forms; `None` on public pages that have no forms. pub csrf_token: CsrfTokenOption, /// Logged-in user context for the site header; `None` when not authenticated. pub session_user: Option, } /// Landing page. #[derive(Template)] #[template(path = "pages/index.html")] pub struct IndexTemplate { pub csrf_token: CsrfTokenOption, pub host_url: Arc, pub total_creators: u32, pub total_items: u32, /// Whether the founder pricing window is currently open. When true the /// landing page features the founder rate prominently and links to /docs /// /guide/tiers. pub founder_window_open: bool, /// Remaining founder slots (1,000 cap). Only shown when small enough to /// convey urgency; not exposed when comfortably above the cap. pub founder_slots_remaining: Option, pub tier_prices: crate::tier_prices::TierPrices, /// Screenshot frames for the "see the platform" carousel. Currently /// placeholders; swap the images (and tighten the alt text) once real /// captures exist. Empty hides the section. pub landing_carousel: Vec, /// The "Last shipped" velocity line, drawn from the most recent published, /// landing-flagged changelog post. `None` suppresses the line entirely /// (no placeholder), same honesty rule the runway disclosure uses. pub last_shipped: Option, /// Result of a no-JS notify submission, carried back through the redirect: /// `Some(true)` subscribed, `Some(false)` the address was rejected, `None` /// a plain page load. The JS path renders its status inline instead and /// leaves this `None`. pub notify_ok: Option, } /// One-line "Last shipped" velocity signal for the landing page. pub struct LandingVelocity { /// Post title. pub title: String, /// Publication date, preformatted (e.g. "Jun 07, 2026"). pub date: String, /// Link target: `/changelog/{slug}`. pub href: String, } /// User's library shell with inline purchases tab (other tabs loaded via HTMX). #[derive(Template)] #[template(path = "pages/library.html")] pub struct LibraryTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, /// The tab strip and its panels, described rather than written out here. /// /// `6b24f2df`. Built by `crate::quasi::library_tabs`, which needs the shown /// panel's markup and the two membership tests, so the handler assembles it /// and this carries the answer. `purchases` and `subscriptions` left with /// the strip: the only thing that read them was the include that is now /// inside it. pub tabs: String, } /// Shopping cart page with items grouped by seller. #[derive(Template)] #[template(path = "pages/cart.html")] pub struct CartTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub seller_groups: Vec, pub wishlist_suggestions: Vec, pub total_items: usize, /// Set to "partial" when a multi-seller checkout partially succeeded. pub checkout_status: String, /// Whether any seller in the cart might need converting for this buyer. pub offer_conversion_choice: bool, /// The buyer's stored conversion preference. pub conversion: crate::currency::ConversionChoice, } impl CartTemplate { /// Whether the stored preference is convert-at-checkout. pub fn conversion_at_checkout(&self) -> bool { matches!( self.conversion, crate::currency::ConversionChoice::AtCheckout ) } } /// A group of cart items from the same seller. pub struct CartSellerGroup { pub seller_username: String, pub seller_id: String, pub stripe_ready: bool, pub items: Vec, /// i64 so a large cart can't overflow when per-item cents are summed (Pay-M3); /// the per-item price is i32 but the running total is widened. pub subtotal_cents: i64, pub item_count: usize, /// How much the creator saves vs. individual purchases ($0.30 per extra item). pub savings_cents: i32, /// The seller's settlement currency. One group is one seller, so one /// currency, which is also why a cart never needs splitting on currency. pub currency: crate::currency::SettlementCurrency, /// Whether to offer the buyer a conversion choice for this seller. /// /// False only when we positively know the buyer's own currency and it /// matches, in which case there is nothing to convert and the control would /// be noise. See `cart_conversion_applies` for why "positively know" is /// narrower than it sounds. pub offer_conversion_choice: bool, /// The buyer's stored preference, so the form comes back the way they left /// it rather than resetting to the default on every visit. pub conversion: crate::currency::ConversionChoice, } /// Whether a conversion choice is worth showing for a seller in this currency. /// /// `buyer_currency` is `Some` only when MNW actually knows it, which is a /// narrower thing than it looks: `settlement_currency` defaults to USD for every /// account, so it is evidence of a buyer's own currency only once they have /// connected Stripe. For everyone else it is a default, not a fact, and treating /// it as one would hide the control from exactly the UK fan who needs it. /// /// So the rule is: hide only on positive knowledge of a match, otherwise offer /// the choice and word it conditionally. MNW cannot know a card's currency in /// advance, and Stripe itself only decides presentment at checkout time. pub fn cart_conversion_applies( buyer_currency: Option, seller_currency: crate::currency::SettlementCurrency, ) -> bool { buyer_currency != Some(seller_currency) } impl CartSellerGroup { /// Whether this group's prices are in a currency the buyer might not hold. pub fn conversion_at_checkout(&self) -> bool { matches!( self.conversion, crate::currency::ConversionChoice::AtCheckout ) } /// The seller's currency symbol, for amounts the template frames itself /// (the PWYW input prefix, the zero platform fee). pub fn currency_symbol(&self) -> &'static str { self.currency.symbol() } pub fn subtotal_display(&self) -> String { crate::formatting::format_revenue(self.subtotal_cents, self.currency) } pub fn savings_display(&self) -> String { crate::formatting::format_revenue(self.savings_cents as i64, self.currency) } } /// Login page. #[derive(Template)] #[template(path = "pages/login.html")] pub struct LoginTemplate { pub csrf_token: CsrfTokenOption, /// Re-displayed in the username/email input on validation failure so the /// user doesn't have to retype it. Empty on the first GET. pub prefill_login: String, /// Shown inline above the form on a failed POST. None hides the banner. pub error: Option, /// Neutral informational notice above the form (e.g. the access-gate prompt /// on the testnot staging mirror). None hides it. Separate from `error` so /// it doesn't render as a failure. pub notice: Option, /// When true, the page shows a single "Sign in with Makenotwork" button /// (delegated SSO) instead of the local password form. Set on the testnot /// mirror where `[sso]` is configured. pub sso_enabled: bool, } // Join Wizard /// Full page: join/signup wizard. #[derive(Template)] #[template(path = "wizards/wizard_join.html")] pub struct WizardJoinTemplate { pub csrf_token: CsrfTokenOption, pub nav: Vec, pub invite_code: Option, /// Preserved input + error on a non-HTMX account-step re-render (so a /// JS-disabled submit that fails validation doesn't drop the typed /// username/email). Empty / `None` on the initial render. pub username: String, pub email: String, pub error: Option, pub error_field: Option, } /// Step 1 partial: account creation (back-nav reload, and the HTMX validation /// re-render). Carries preserved input + error so a failed HTMX submit swaps the /// form back with the typed username/email intact and the bad field flagged, /// instead of replacing the whole step with a bare error line (UX-S1, Run #23). #[derive(Template)] #[template(path = "wizards/steps/join/account.html")] pub struct WizardJoinAccountTemplate { pub nav: Vec, pub csrf_token: CsrfTokenOption, pub invite_code: Option, pub username: String, pub email: String, pub error: Option, pub error_field: Option, } /// Step 2 partial: profile (display name + bio). #[derive(Template)] #[template(path = "wizards/steps/join/profile.html")] pub struct WizardJoinProfileTemplate { pub nav: Vec, } /// Step 3 partial: welcome/complete with intent branching. #[derive(Template)] #[template(path = "wizards/steps/join/complete.html")] pub struct WizardJoinCompleteTemplate { pub nav: Vec, pub display_name: String, /// Whether this user already has creator access. pub is_creator: bool, /// Whether this user arrived via invite (already has waitlist entry). pub has_invite: bool, } /// Two-factor authentication verification page (login flow). #[derive(Template)] #[template(path = "pages/two_factor.html")] pub struct TwoFactorTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub error: Option, } /// OAuth2 authorization / consent page. #[derive(Template)] #[template(path = "pages/oauth_authorize.html")] pub struct OAuthAuthorizeTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub app_name: String, pub client_id: String, pub redirect_uri: String, pub state: String, pub code_challenge: String, pub code_challenge_method: String, /// Space-delimited requested scope, round-tripped through the consent form /// so the granted scope survives the GET -> POST hop. pub scope: String, pub error_message: Option, } /// Forgot password form. #[derive(Template)] #[template(path = "pages/forgot_password.html")] pub struct ForgotPasswordTemplate { pub csrf_token: CsrfTokenOption, } /// Password reset form (reached via email link). #[derive(Template)] #[template(path = "pages/reset_password.html")] pub struct ResetPasswordTemplate { pub csrf_token: CsrfTokenOption, pub valid: bool, /// The single-use reset token, round-tripped through the form's hidden /// field so the POST can consume it. Empty when the link is invalid. pub token: String, /// Inline error banner (e.g. "Passwords do not match"). None hides the /// banner. Used on non-HTMX form-validation failures so the user stays /// on the form with the token field intact. pub error: Option, } /// Public user profile page. #[derive(Template)] #[template(path = "pages/user.html")] #[allow(dead_code)] // Fields used by Askama template pub struct UserTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub user: User, pub custom_links: Vec, pub projects: Vec, pub public_collections: Vec, /// User ID for the follow button target. pub user_id: String, /// Whether the current viewer is looking at their own profile. pub is_own_profile: bool, /// Whether the current viewer is following this user. pub is_following: bool, /// Total follower count for this user. pub follower_count: i64, /// Base URL for OG meta tags. pub host_url: Arc, /// Whether this creator has voluntarily paused their account. pub creator_paused: bool, /// Whether this creator accepts tips. pub tips_enabled: bool, /// Creator's user ID for tip checkout (string for template use). pub creator_id: String, /// Project ID for tip attribution (None on user profile pages). pub tip_project_id: Option, /// Pre-rendered primitive-layer CSS for this creator's chosen theme, /// injected into the page `` (Tier 0). See `crate::theming`. pub theme_css: &'static str, } /// Public collection page (shareable URL). #[derive(Template)] #[template(path = "pages/collection.html")] #[allow(dead_code)] // Fields used by Askama template pub struct CollectionTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub collection: Collection, pub items: Vec, pub owner_username: String, pub owner_display_name: Option, pub is_owner: bool, } /// Public project page with item listing. #[derive(Template)] #[template(path = "pages/project.html")] #[allow(dead_code)] // Fields used by Askama template pub struct ProjectTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub project: Project, pub creator_username: String, pub items: Vec, /// Project ID for the follow button target. pub project_id: String, /// Whether the current viewer is following this project. pub is_following: bool, /// Total follower count for this project. pub follower_count: i64, /// Active subscription tiers available for this project. pub subscription_tiers: Vec, /// Whether the current viewer already has an active subscription. pub has_subscription: bool, /// Base URL for OG meta tags. pub host_url: Arc, /// Linked git repositories: (name, URL) pairs. pub git_repos: Vec<(String, String)>, /// Whether this project has any published blog posts. pub has_blog_posts: bool, /// URL to the paired MT community forum (None if no community provisioned). pub community_url: Option, /// Whether the project owner accepts tips. pub tips_enabled: bool, /// Creator's user ID for tip checkout (string for template use). pub creator_id: String, /// Project ID for tip attribution. pub tip_project_id: Option, /// Whether the current viewer owns this project. pub is_owner: bool, /// Tabbed markdown sections (privacy, terms, FAQ, etc). pub sections: Vec, /// Ordered gallery images rendered through the shared carousel widget /// (empty → the carousel section is suppressed). Additive to cover_image_url. pub gallery: Vec, /// Pre-rendered primitive-layer CSS for this project's chosen theme, /// injected into the page `` (Tier 0). See `crate::theming`. pub theme_css: &'static str, } /// Project paywall landing page (shown when a project requires purchase/subscription). #[derive(Template)] #[template(path = "pages/project_paywall.html")] pub struct ProjectPaywallTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub project: Project, pub creator_username: String, /// Human-readable pricing (e.g. "$19.99", "Subscription"). pub price_display: String, /// What kind of checkout flow is needed. pub checkout_type: crate::pricing::CheckoutType, /// Available subscription tiers (for subscription-model projects). pub subscription_tiers: Vec, /// Base URL for OG meta tags. pub host_url: Arc, } /// Public item detail page. #[derive(Template)] #[template(path = "pages/item.html")] #[allow(dead_code)] // Fields used by Askama template pub struct ItemTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, /// Uppercase ISO code for the JSON-LD `priceCurrency`. Structured data is /// read by machines that will not second-guess it, so a hardcoded USD here /// would misprice a non-USD creator's work in search results. pub price_currency: &'static str, pub project_title: String, pub project_slug: String, /// Base URL for OG meta tags. pub host_url: Arc, /// URL to the MT discussion thread (None if no linked thread or MT unavailable). pub discussion_url: Option, /// Number of posts in the linked discussion thread. pub discussion_count: Option, /// Project cover image URL (fallback for og:image when item has no cover). pub project_cover_image_url: Option, /// Child items for bundle-type items (empty for non-bundles). pub bundle_items: Vec, /// Bundles containing this item (for unlisted items, to show "Available in" links). pub containing_bundles: Vec, /// Tabbed content sections (e.g. Features, Installation, Specs). pub sections: Vec, /// Whether the current user is the item's creator (for dashboard links). pub is_owner: bool, /// Whether the current user has wishlisted this item. pub is_wishlisted: bool, /// Whether the current user has this item in their cart. pub in_cart: bool, /// How many of the current user's collections contain this item. pub collection_count: u32, /// Whether the current user can consume this item (purchased, free, subscribed, creator, bundle). /// Drives the store-page CTA swap: true → "View in library", false → Buy/PWYW. pub has_access: bool, /// Ordered gallery images rendered through the shared carousel widget /// (empty → the carousel section is suppressed). Additive to cover_image_url. pub gallery: Vec, /// Pre-rendered primitive-layer CSS for the parent project's chosen theme /// (items inherit it), injected into `` (Tier 0). See `crate::theming`. pub theme_css: &'static str, } /// Library (consumption) view for download / bundle / other items. /// Audio + video items currently render this too; dedicated templates land in /// Phases 2–3. #[derive(Template)] #[template(path = "pages/library_downloads.html")] #[allow(dead_code)] pub struct LibraryDownloadsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub project_title: String, pub project_slug: String, pub host_url: Arc, pub versions: Vec, /// Child items if this is a bundle; otherwise empty. Children get `/l/` links /// because the viewer (by being on this page) has access via the bundle. pub bundle_items: Vec, pub sections: Vec, pub discussion_url: Option, pub discussion_count: Option, pub is_owner: bool, } /// 403 page shown when a viewer hits /l/{id} but lacks access. #[derive(Template)] #[template(path = "pages/library_locked.html")] #[allow(dead_code)] pub struct LibraryLockedTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub host_url: Arc, /// For unlisted items: bundles that contain this item. pub containing_bundles: Vec, pub is_logged_in: bool, } /// Library (consumption) view for text items, full article body, discussion. #[derive(Template)] #[template(path = "pages/library_text.html")] #[allow(dead_code)] pub struct LibraryTextTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, pub creator_avatar_initials: String, pub project_title: String, pub project_slug: String, /// Fully rendered article body HTML. pub body_html: Option, pub reading_time: Option, pub host_url: Arc, pub discussion_url: Option, pub discussion_count: Option, pub is_owner: bool, } /// Blog/article reader view. #[derive(Template)] #[template(path = "pages/text_reader.html")] #[allow(dead_code)] // Fields used by Askama template pub struct TextReaderTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, /// First-letter initials for the avatar circle (e.g. "JD" for "Jane Doe"). pub creator_avatar_initials: String, pub project_title: String, pub project_slug: String, /// Whether the item has a zero price (free content, no purchase required). pub is_free: bool, /// Whether the current user already has this item in their library. pub in_library: bool, /// Drives the CTA swap: true → "Read in library", false → Buy/PWYW/Add-to-Library. pub has_access: bool, pub reading_time: Option, /// Short plain-text preview of the article body, shown on the store page. pub excerpt: Option, /// Base URL for OG meta tags. pub host_url: Arc, /// URL to the MT discussion thread (None if no linked thread or MT unavailable). pub discussion_url: Option, /// Number of posts in the linked discussion thread. pub discussion_count: Option, } /// Library (consumption) view for audio items, full player, chapters, /// description, optional source-file downloads, discussion. #[derive(Template)] #[template(path = "pages/library_audio.html")] #[allow(dead_code)] pub struct LibraryAudioTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, pub creator_avatar_initials: String, pub project_title: Option, pub project_slug: String, pub audio_url: Option, pub chapters: Vec, pub segments_json: String, /// Source-file downloads if the creator offers them alongside the stream. pub versions: Vec, pub host_url: Arc, pub discussion_url: Option, pub discussion_count: Option, pub is_owner: bool, } /// Audio streaming player view. #[derive(Template)] #[template(path = "pages/audio_player.html")] pub struct AudioPlayerTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, /// First-letter initials for the avatar circle. pub creator_avatar_initials: String, pub project_title: Option, pub project_slug: String, /// Whether the item has a zero price. pub is_free: bool, /// Whether the current user already has this item in their library. pub in_library: bool, /// Drives the CTA swap: true → "View in library", false → Buy/PWYW or Add-to-Library. pub has_access: bool, /// Base URL for OG meta tags. pub host_url: Arc, /// URL to the MT discussion thread (None if no linked thread or MT unavailable). pub discussion_url: Option, /// Number of posts in the linked discussion thread. pub discussion_count: Option, } /// Library (consumption) view for video items, full player, chapters, /// description, optional source-file downloads, discussion. #[derive(Template)] #[template(path = "pages/library_video.html")] #[allow(dead_code)] pub struct LibraryVideoTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, pub creator_avatar_initials: String, pub project_title: Option, pub project_slug: String, pub video_url: Option, pub chapters: Vec, pub segments_json: String, pub versions: Vec, pub host_url: Arc, pub discussion_url: Option, pub discussion_count: Option, pub is_owner: bool, } /// Video player page with custom controls, insertions, chapters. #[derive(Template)] #[template(path = "pages/video_player.html")] pub struct VideoPlayerTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub item: Item, pub creator_username: String, pub creator_display_name: Option, pub creator_avatar_initials: String, pub project_title: Option, pub project_slug: String, pub is_free: bool, pub in_library: bool, pub has_access: bool, pub host_url: Arc, pub discussion_url: Option, pub discussion_count: Option, } /// Browse/discover page with filtering and pagination. #[derive(Template)] #[template(path = "pages/discover.html")] pub struct DiscoverTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub items: Vec, pub projects: Vec, /// Active browse mode: `"items"` or `"projects"`. pub mode: String, pub total_items: u32, pub current_page: u32, pub total_pages: u32, pub search_query: String, /// A search term was applied. Drives the empty-state copy. /// /// Not `!search_query.is_empty()`: that is the raw `?q=`, and a /// whitespace-only term is browsing as far as the query is concerned. pub is_search: bool, /// The rendered count line ("247 results", "1 item"). Built once in /// `results_count_label` because the page and the out-of-band partial both /// render `#total-count`; a difference between them would show up as the /// text changing on the first HTMX swap. pub count_label: String, /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`). pub sort_by: String, /// Page numbers to render in the pagination bar. pub pagination_range: Vec, pub showing_start: u32, pub showing_end: u32, /// Everything the sidebar renders. Grouped so the results partial can carry /// the identical set for its out-of-band swap. pub sidebar: SidebarView, /// Whether the current user is authenticated (for collection save buttons in results). pub is_authenticated: bool, /// Always false here: the page renders the sidebar directly, so the /// included results partial must not emit a second out-of-band copy. pub oob_sidebar: bool, } /// Tag tree browser with breadcrumb navigation. #[derive(Template)] #[template(path = "pages/tag_tree.html")] pub struct TagTreeTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub categories: Vec, pub breadcrumbs: Vec, pub current_tag: Option, } /// Purchase confirmation page showing fee breakdown. #[derive(Template)] #[template(path = "pages/purchase.html")] pub struct PurchaseTemplate { pub csrf_token: CsrfTokenOption, pub item: Item, pub creator_username: String, /// The creator's currency symbol, for the amount fields the template frames /// itself (the PWYW input prefix, the zero platform fee). Amounts formatted /// in Rust already carry their own symbol and must not be prefixed again. pub currency_symbol: &'static str, /// Whether the processing-fee breakdown can be shown with real numbers. /// False outside USD, where MNW does not model Stripe's local pricing. pub show_fee_estimate: bool, pub stripe_fee: String, pub creator_receives: String, /// Pre-filled promo code from `?code=` query parameter. pub promo_code: String, /// Whether PWYW pricing is enabled for this item. pub pwyw_enabled: bool, /// Minimum price in cents when PWYW is enabled. pub pwyw_min_cents: i32, /// Formatted suggested price in dollars (e.g. "9.99"). pub suggested_price: String, /// Formatted minimum price in dollars (e.g. "1.00"). pub pwyw_min_dollars: String, /// Whether the creator has Stripe Tax enabled. pub stripe_tax_enabled: bool, /// Whether the current visitor is logged in (show guest checkout if not). pub is_logged_in: bool, /// If the buyer has an in-progress (pending) checkout for this item, /// the relative time it was started (e.g. "5 minutes ago"). Empty /// string means no pending checkout. pub pending_started: String, } /// Minimal direct purchase page, no navigation, for link-in-bio sharing. #[derive(Template)] #[template(path = "pages/buy.html")] pub struct BuyPageTemplate { pub item: Item, pub creator_username: String, /// The creator's currency symbol, for the PWYW input prefix. pub currency_symbol: &'static str, pub creator_display_name: Option, pub pwyw_enabled: bool, pub pwyw_min_dollars: String, pub suggested_price: String, pub host_url: Arc, } /// Feed page showing items from followed users, projects, and tags. #[derive(Template)] #[template(path = "pages/feed.html")] pub struct FeedTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub items: Vec, pub total_items: u32, pub current_page: u32, pub total_pages: u32, pub pagination_range: Vec, pub showing_start: u32, pub showing_end: u32, } /// Public page: Stripe Connect disclaimer and terms before onboarding. #[derive(Template)] #[template(path = "pages/stripe_disclaimer.html")] pub struct StripeConnectDisclaimerTemplate { pub csrf_token: CsrfTokenOption, } // Fan+ /// Fan+ subscription page: marketing, subscribe, or manage. #[derive(Template)] #[template(path = "pages/fan_plus.html")] pub struct FanPlusTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, /// Whether the user has an active Fan+ subscription. pub is_subscribed: bool, /// Current billing period end (if subscribed). pub period_end: Option, /// Whether a `?subscribed=true` query was present (just subscribed). pub just_subscribed: bool, } // Blog Pages /// Public blog index for a project. #[derive(Template)] #[template(path = "pages/project_blog.html")] pub struct ProjectBlogTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub project: Project, pub creator_username: String, pub project_slug: String, pub posts: Vec, } /// Public blog post reader. #[derive(Template)] #[template(path = "pages/blog_post.html")] pub struct BlogPostTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub title: String, /// Title escaped for JSON string embedding (JSON-LD). pub title_json: String, pub body_html: String, pub published_at: String, pub creator_username: String, pub creator_display_name: Option, pub creator_avatar_initials: String, pub project_title: String, /// Project title escaped for JSON string embedding (JSON-LD). pub project_title_json: String, pub project_slug: String, /// URL-safe slug for this blog post. pub post_slug: String, /// Base URL for OG meta tags. pub host_url: Arc, /// Project cover image URL (fallback for og:image when blog post has no specific image). pub project_cover_image_url: Option, /// URL to the MT discussion thread (None if no linked thread or MT unavailable). pub discussion_url: Option, /// Number of posts in the linked discussion thread. pub discussion_count: Option, } // Documentation Pages /// Individual documentation page. #[derive(Template)] #[template(path = "pages/doc.html")] pub struct DocTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub title: String, pub section: String, pub content: String, /// Pages that link to this one ("what links here"), in docs-index order. /// Empty when nothing links here, in which case the template omits the /// section entirely. pub backlinks: Vec, } /// Entry in a doc section for the index page. pub struct DocSectionEntry { pub title: String, pub slug: String, } /// A collapsible subcategory within a doc section. pub struct DocSubsection { pub label: String, pub entries: Vec, } /// A group of doc entries under a section heading. pub struct DocSection { pub name: String, pub entries: Vec, pub subsections: Vec, } /// Documentation index page listing all docs by section. #[derive(Template)] #[template(path = "pages/doc_index.html")] pub struct DocIndexTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub sections: Vec, } // Pricing Calculator /// Interactive fee calculator: MNW against a platform the visitor describes. /// No competitor is named or held on file; see `crate::fee_calculator`. #[derive(Template)] #[template(path = "pages/pricing.html")] pub struct PricingTemplate { pub csrf_token: CsrfTokenOption, pub tier_prices: crate::tier_prices::TierPrices, /// Whether founder pricing is on offer right now. Gates both the banner /// and the list/founder toggle: with the window shut the page renders /// exactly as it did before either existed, list prices and no dead /// control. Same source the landing page reads, so the two cannot /// disagree about whether the offer is live. pub founder_window_open: bool, /// Dial positions this render used, echoed back into the input values so /// a shared or reloaded URL comes back to the same scenario. pub inputs: crate::fee_calculator::Inputs, /// `inputs.other_pct` as whole percent for the input box (`12.6`, not /// `0.126`). Preformatted so the template does no arithmetic. pub other_pct_display: String, pub other_per_sale_display: String, /// Server-computed opening result. The page re-fetches the partial via /// HTMX as the dials change. pub outcome: crate::fee_calculator::Outcome, } /// HTMX partial: the recomputed calculator (verdict, both take-homes, and the /// win-region bar), swapped into `/pricing` on any dial change. #[derive(Template)] #[template(path = "partials/fee_calculator.html")] pub struct FeeCalculatorPartial { pub outcome: crate::fee_calculator::Outcome, } /// Platform economics + runway disclosure page. Renders at `/economics` /// (the retired markdown page's `/docs/economics` URL 301s here). See the /// doc comment on `templates/pages/economics.html` for the maintenance /// contract. #[derive(Template)] #[template(path = "pages/economics.html")] #[allow(dead_code)] // Fields used by Askama template pub struct EconomicsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, /// `quarters` + `last_updated_iso` from `[runway]` in /// `assumptions.toml`. Operator-edited; refreshed quarterly. pub runway_config: crate::tier_prices::RunwayConfig, /// Live count of `status='active'` creator subscriptions. Pulled at /// request time so the page never lies about how many seats are /// revenue-bearing right now. pub paying_creators: i64, /// Live count of `status='trialing'` plus canceled-with-grace /// creators. Disclosed only when non-zero (the template hides the /// bullet otherwise so a quiet platform doesn't show "0 in trial"). pub trialing_or_grace: i64, } /// Use cases page showcasing creator types. #[derive(Template)] #[template(path = "pages/use_cases.html")] pub struct UseCasesTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub tier_prices: crate::tier_prices::TierPrices, } /// Team page listing the founder, residents, and fellows. #[derive(Template)] #[template(path = "pages/team.html")] pub struct TeamTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, } // Creator Invite System /// Public page: creator signup, tier pricing, and the active-creator count. #[derive(Template)] #[template(path = "pages/creators.html")] pub struct CreatorsTemplate { pub csrf_token: CsrfTokenOption, pub session_user: Option, pub total_creators: u32, pub is_creator: bool, pub tier_prices: crate::tier_prices::TierPrices, } // Email & Account /// Public page: email action result (verification, unsubscribe, etc.). #[derive(Template)] #[template(path = "pages/email_result.html")] pub struct EmailResultTemplate { pub csrf_token: CsrfTokenOption, pub title: String, pub message: String, pub link_url: String, pub link_text: String, } /// Public page: email preferences, reached from a signed link in any email. /// /// Sessionless on purpose. Somebody who wants out of our email should not have /// to remember a password first, and the signed token is the authorisation. #[derive(Template)] #[template(path = "pages/email_preferences.html")] pub struct EmailPreferencesTemplate { pub csrf_token: CsrfTokenOption, /// The signed token, echoed into each form so every action on the page /// carries the same authorisation the page itself did. pub token: String, pub signature: String, /// The subscription the link was minted for, highlighted so the page /// answers "this one" before it offers the rest. pub origin_subscription: crate::db::ListSubscriptionId, pub subscriptions: Vec, } /// Purchase receipt page. #[derive(Template)] #[template(path = "pages/receipt.html")] pub struct ReceiptTemplate { pub csrf_token: CsrfTokenOption, /// The symbol of the currency this sale was denominated in, for the zero /// platform-fee line. `amount` already carries its own. pub currency_symbol: &'static str, /// What the buyer was actually charged, pre-formatted, when Stripe /// converted at checkout. Empty when there was no conversion. /// /// This is the exact figure the disclosure at checkout could only give a /// range for: Stripe reports it after payment and never breaks out the fee /// inside it, so the receipt is the first and only place a buyer can /// reconcile against their statement. pub presented_amount: String, pub session_user: Option, pub transaction_id: String, pub item_id: String, pub item_title: String, pub seller_username: String, pub amount: String, pub is_free: bool, pub status: String, pub date: String, } /// Confirmation page shown before account deletion (GET step). #[derive(Template)] #[template(path = "pages/confirm_delete.html")] pub struct ConfirmDeleteTemplate { pub csrf_token: CsrfTokenOption, pub user: String, pub expires: String, pub sig: String, } /// The acknowledgement page an alert's link lands on. /// /// Sessionless: whoever holds the link sees it. That is why it carries only /// what the alert is about and nothing identifying the account, and why the /// button records an acknowledgement rather than changing anything. #[derive(Template)] #[template(path = "pages/acknowledge.html")] pub struct AcknowledgeTemplate { /// Always `None`. The route is CSRF-skip because holding the link is the /// authorisation, and the page is reached without a session, so there is no /// token to render. `base.html` requires the field. pub csrf_token: CsrfTokenOption, pub title: String, /// Body copy already split into paragraphs, because the source is a mail /// body with blank lines in it and the template should not be parsing prose. pub detail: Vec, pub token: String, pub acknowledged: bool, } /// Public page: confirmation that account has been deleted. #[derive(Template)] #[template(path = "pages/account-deleted.html")] pub struct AccountDeletedTemplate { pub csrf_token: CsrfTokenOption, } #[cfg(test)] mod conversion_visibility_tests { use super::cart_conversion_applies; use crate::currency::SettlementCurrency; #[test] fn an_unknown_buyer_currency_always_gets_the_choice() { // The common case: a fan with no Stripe account. We know nothing about // the card they will pay with, so we must not decide for them. for seller in SettlementCurrency::ALL { assert!(cart_conversion_applies(None, seller), "{seller}"); } } #[test] fn a_known_match_hides_the_choice() { assert!(!cart_conversion_applies( Some(SettlementCurrency::Gbp), SettlementCurrency::Gbp )); } #[test] fn a_known_mismatch_shows_the_choice() { assert!(cart_conversion_applies( Some(SettlementCurrency::Gbp), SettlementCurrency::Usd )); } }