Skip to main content

max / makenotwork

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