max / makenotwork
8 files changed,
+469 insertions,
-330 deletions
| @@ -1275,7 +1275,7 @@ | |||
| 1275 | 1275 | Variants: | |
| 1276 | 1276 | .card: filled, hover-step (default content card). | |
| 1277 | 1277 | .card.card-muted: surface-muted fill, no hover (dashboard stat / analytics blocks). | |
| 1278 | - | .card.card--bordered: bordered, padded, no fill (marketing cards: feature/tier/use-case). | |
| 1278 | + | .card.card--bordered: bordered, padded, no fill (marketing cards: tier, fork). | |
| 1279 | 1279 | .card.card--selectable: radio-card pattern; pair with .is-selected. | |
| 1280 | 1280 | .card.card--grid: grid-cell card (discover grid). | |
| 1281 | 1281 | No aliases. A template names the modifier it wants and adds its own | |
| @@ -1305,15 +1305,15 @@ | |||
| 1305 | 1305 | padding: var(--gap-pane); | |
| 1306 | 1306 | } | |
| 1307 | 1307 | /* Bordered card. Templates put this modifier on the element alongside any | |
| 1308 | - | component class of their own (.tier-card, .use-case-card, .fork-card), so | |
| 1309 | - | the name the charter teaches is the name in the markup. */ | |
| 1308 | + | component class of their own (.tier-card, .fork-card), so the name the | |
| 1309 | + | charter teaches is the name in the markup. A described screen names none of | |
| 1310 | + | them: a card there is a region, and the design system draws it. */ | |
| 1310 | 1311 | .card--bordered { | |
| 1311 | 1312 | background: transparent; | |
| 1312 | 1313 | padding: var(--gap-section); | |
| 1313 | 1314 | border: 1px solid var(--border); | |
| 1314 | 1315 | margin-bottom: 0; | |
| 1315 | 1316 | } | |
| 1316 | - | .use-case-card { padding: var(--gap-section); } | |
| 1317 | 1317 | .fork-card { padding: var(--gap-pane); display: flex; flex-direction: column; } | |
| 1318 | 1318 | ||
| 1319 | 1319 | .card-title { | |
| @@ -6223,14 +6223,6 @@ | |||
| 6223 | 6223 | border-style: dashed; | |
| 6224 | 6224 | position: relative; | |
| 6225 | 6225 | } | |
| 6226 | - | .landing-cta { | |
| 6227 | - | display: flex; | |
| 6228 | - | align-items: center; | |
| 6229 | - | justify-content: center; | |
| 6230 | - | gap: var(--gap-page); | |
| 6231 | - | width: fit-content; | |
| 6232 | - | margin: var(--gap-pane) auto var(--gap-page); | |
| 6233 | - | } | |
| 6234 | 6226 | .secondary-links { | |
| 6235 | 6227 | display: flex; | |
| 6236 | 6228 | gap: var(--gap-pane); | |
| @@ -6260,25 +6252,6 @@ | |||
| 6260 | 6252 | opacity: 1; | |
| 6261 | 6253 | } | |
| 6262 | 6254 | ||
| 6263 | - | .feature-grid { | |
| 6264 | - | display: grid; | |
| 6265 | - | grid-template-columns: 1fr 1fr; | |
| 6266 | - | gap: var(--gap-section); | |
| 6267 | - | text-align: left; | |
| 6268 | - | } | |
| 6269 | - | ||
| 6270 | - | .feature-name { | |
| 6271 | - | font-family: var(--font-mono); | |
| 6272 | - | font-size: var(--text-note); | |
| 6273 | - | font-weight: 600; | |
| 6274 | - | margin-bottom: var(--gap-bound); | |
| 6275 | - | } | |
| 6276 | - | ||
| 6277 | - | .feature-desc { | |
| 6278 | - | font-size: var(--text-note); | |
| 6279 | - | opacity: 0.7; | |
| 6280 | - | } | |
| 6281 | - | ||
| 6282 | 6255 | .how-list { | |
| 6283 | 6256 | list-style: none; | |
| 6284 | 6257 | margin: 0; | |
| @@ -6407,24 +6380,6 @@ | |||
| 6407 | 6380 | border-bottom-color: currentColor; | |
| 6408 | 6381 | } | |
| 6409 | 6382 | ||
| 6410 | - | .explore-links { | |
| 6411 | - | display: flex; | |
| 6412 | - | flex-wrap: wrap; | |
| 6413 | - | gap: var(--gap-section) var(--gap-pane); | |
| 6414 | - | justify-content: center; | |
| 6415 | - | } | |
| 6416 | - | ||
| 6417 | - | .explore-links a { | |
| 6418 | - | font-family: var(--font-mono); | |
| 6419 | - | font-size: var(--text-note); | |
| 6420 | - | opacity: 0.7; | |
| 6421 | - | transition: opacity 0.2s ease; | |
| 6422 | - | } | |
| 6423 | - | ||
| 6424 | - | .explore-links a:hover { | |
| 6425 | - | opacity: 1; | |
| 6426 | - | } | |
| 6427 | - | ||
| 6428 | 6383 | /* =========================================== | |
| 6429 | 6384 | PRICING CALCULATOR | |
| 6430 | 6385 | =========================================== */ | |
| @@ -6723,73 +6678,19 @@ | |||
| 6723 | 6678 | USE CASES PAGE | |
| 6724 | 6679 | =========================================== */ | |
| 6725 | 6680 | ||
| 6726 | - | .use-cases-page { | |
| 6681 | + | /* The use-cases page moved to the description layer; what the template's own | |
| 6682 | + | classes said is drawn by the design system now. What is left is the measure, | |
| 6683 | + | which a described document carries on <main> rather than on a container div, | |
| 6684 | + | the same shape as `.team-page main` and `.feed-page main`. | |
| 6685 | + | ||
| 6686 | + | The grid went with it deliberately: nine equal cards are nine sibling groups | |
| 6687 | + | in the description, and how they sit is the design system's answer for every | |
| 6688 | + | consumer rather than a `grid-template-columns` here. */ | |
| 6689 | + | .use-cases-page main { | |
| 6727 | 6690 | max-width: 900px; | |
| 6728 | 6691 | margin: 0 auto; | |
| 6729 | 6692 | } | |
| 6730 | 6693 | ||
| 6731 | - | .use-cases-intro { | |
| 6732 | - | font-size: var(--text-lead); | |
| 6733 | - | margin-bottom: var(--gap-page); | |
| 6734 | - | line-height: 1.6; | |
| 6735 | - | } | |
| 6736 | - | ||
| 6737 | - | .use-case-grid { | |
| 6738 | - | display: grid; | |
| 6739 | - | grid-template-columns: 1fr 1fr 1fr; | |
| 6740 | - | gap: var(--gap-section); | |
| 6741 | - | text-align: left; | |
| 6742 | - | } | |
| 6743 | - | ||
| 6744 | - | .use-case-grid-2col { | |
| 6745 | - | grid-template-columns: 1fr 1fr; | |
| 6746 | - | } | |
| 6747 | - | ||
| 6748 | - | .use-case-title { | |
| 6749 | - | font-family: var(--font-display); | |
| 6750 | - | font-size: var(--text-lead); | |
| 6751 | - | margin-bottom: var(--gap-bound); | |
| 6752 | - | } | |
| 6753 | - | ||
| 6754 | - | .use-case-who { | |
| 6755 | - | font-family: var(--font-mono); | |
| 6756 | - | font-size: var(--text-fine); | |
| 6757 | - | opacity: 0.6; | |
| 6758 | - | margin-bottom: var(--gap-peer); | |
| 6759 | - | } | |
| 6760 | - | ||
| 6761 | - | .use-case-desc { | |
| 6762 | - | font-size: var(--text-note); | |
| 6763 | - | line-height: 1.5; | |
| 6764 | - | margin-bottom: var(--gap-peer); | |
| 6765 | - | } | |
| 6766 | - | ||
| 6767 | - | .use-case-features { | |
| 6768 | - | list-style: none; | |
| 6769 | - | margin: 0 0 var(--gap-section); | |
| 6770 | - | padding: 0; | |
| 6771 | - | } | |
| 6772 | - | ||
| 6773 | - | .use-case-features li { | |
| 6774 | - | font-size: var(--text-note); | |
| 6775 | - | padding-left: var(--gap-section); | |
| 6776 | - | position: relative; | |
| 6777 | - | margin-bottom: var(--gap-bound); | |
| 6778 | - | } | |
| 6779 | - | ||
| 6780 | - | .use-case-features li::before { | |
| 6781 | - | content: "-"; | |
| 6782 | - | position: absolute; | |
| 6783 | - | left: 0; | |
| 6784 | - | opacity: 0.5; | |
| 6785 | - | } | |
| 6786 | - | ||
| 6787 | - | .use-case-tier { | |
| 6788 | - | font-family: var(--font-mono); | |
| 6789 | - | font-size: var(--text-fine); | |
| 6790 | - | opacity: 0.5; | |
| 6791 | - | } | |
| 6792 | - | ||
| 6793 | 6694 | /* =========================================== | |
| 6794 | 6695 | RESPONSIVE, 768px (tablet) | |
| 6795 | 6696 | =========================================== */ | |
| @@ -7601,19 +7502,10 @@ | |||
| 7601 | 7502 | ||
| 7602 | 7503 | /* Landing page (merged from 500px) */ | |
| 7603 | 7504 | .landing-fork, | |
| 7604 | - | .tier-selector, | |
| 7605 | - | .feature-grid, | |
| 7606 | - | .use-case-grid, | |
| 7607 | - | .use-case-grid-2col { | |
| 7505 | + | .tier-selector { | |
| 7608 | 7506 | grid-template-columns: 1fr; | |
| 7609 | 7507 | } | |
| 7610 | 7508 | ||
| 7611 | - | .explore-links { | |
| 7612 | - | flex-direction: column; | |
| 7613 | - | align-items: center; | |
| 7614 | - | gap: var(--gap-section); | |
| 7615 | - | } | |
| 7616 | - | ||
| 7617 | 7509 | .pricing-input { | |
| 7618 | 7510 | font-size: var(--text-title); | |
| 7619 | 7511 | } | |
| @@ -7636,11 +7528,6 @@ | |||
| 7636 | 7528 | gap: var(--gap-section); | |
| 7637 | 7529 | } | |
| 7638 | 7530 | ||
| 7639 | - | .landing-cta { | |
| 7640 | - | flex-direction: column; | |
| 7641 | - | gap: var(--gap-section); | |
| 7642 | - | } | |
| 7643 | - | ||
| 7644 | 7531 | .toast-container { | |
| 7645 | 7532 | left: 0.75rem; | |
| 7646 | 7533 | right: 0.75rem; |
| @@ -68,6 +68,7 @@ | |||
| 68 | 68 | pub mod ssh_keys; | |
| 69 | 69 | pub mod team; | |
| 70 | 70 | pub mod upload_field; | |
| 71 | + | pub mod use_cases; | |
| 71 | 72 | pub mod user_analytics; | |
| 72 | 73 | pub mod user_projects; | |
| 73 | 74 | pub mod user_support; | |
| @@ -385,7 +386,7 @@ | |||
| 385 | 386 | /// mechanism: those screens resolve nothing per request and take a state built | |
| 386 | 387 | /// once at startup. These resolve a session when there is one, so they carry a | |
| 387 | 388 | /// per-request viewer and can mint a CSRF token for the form on them. | |
| 388 | - | pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH]; | |
| 389 | + | pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH, use_cases::PATH]; | |
| 389 | 390 | ||
| 390 | 391 | /// Every screen's switch name, in the same order as [`PATHS`]. | |
| 391 | 392 | /// | |
| @@ -562,10 +563,16 @@ | |||
| 562 | 563 | /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in | |
| 563 | 564 | /// [`document_mounts`] nor in [`public_mounts`]. | |
| 564 | 565 | pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { | |
| 565 | - | vec![( | |
| 566 | - | team::PATH, | |
| 567 | - | public_document_mount(app, team::PATH, team::screen, team::renderer), | |
| 568 | - | )] | |
| 566 | + | vec![ | |
| 567 | + | ( | |
| 568 | + | team::PATH, | |
| 569 | + | public_document_mount(app, team::PATH, team::screen, team::renderer), | |
| 570 | + | ), | |
| 571 | + | ( | |
| 572 | + | use_cases::PATH, | |
| 573 | + | public_document_mount(app, use_cases::PATH, use_cases::screen, use_cases::renderer), | |
| 574 | + | ), | |
| 575 | + | ] | |
| 569 | 576 | } | |
| 570 | 577 | ||
| 571 | 578 | /// A described document a reader NAVIGATES to with or without a session. |
| @@ -177,7 +177,6 @@ | |||
| 177 | 177 | // Platform economics + runway disclosure | |
| 178 | 178 | EconomicsTemplate, | |
| 179 | 179 | // Use cases | |
| 180 | - | UseCasesTemplate, | |
| 181 | 180 | // Team | |
| 182 | 181 | // Fan+ | |
| 183 | 182 | FanPlusTemplate, |
| @@ -904,15 +904,6 @@ | |||
| 904 | 904 | pub trialing_or_grace: i64, | |
| 905 | 905 | } | |
| 906 | 906 | ||
| 907 | - | /// Use cases page showcasing creator types. | |
| 908 | - | #[derive(Template)] | |
| 909 | - | #[template(path = "pages/use_cases.html")] | |
| 910 | - | pub struct UseCasesTemplate { | |
| 911 | - | pub csrf_token: CsrfTokenOption, | |
| 912 | - | pub session_user: Option<SessionUser>, | |
| 913 | - | pub tier_prices: crate::tier_prices::TierPrices, | |
| 914 | - | } | |
| 915 | - | ||
| 916 | 907 | // Creator Invite System | |
| 917 | 908 | ||
| 918 | 909 | /// Public page: creator signup, tier pricing, and the active-creator count. |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | templates::{ | |
| 25 | 25 | CarouselFrame, CartTemplate, EconomicsTemplate, FanPlusTemplate, IndexTemplate, | |
| 26 | 26 | LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate, | |
| 27 | - | LibraryTemplate, LoginTemplate, PolicyTemplate, UseCasesTemplate, | |
| 27 | + | LibraryTemplate, LoginTemplate, PolicyTemplate, | |
| 28 | 28 | }, | |
| 29 | 29 | types::{Collection, UserSubscription}, | |
| 30 | 30 | }; | |
| @@ -485,20 +485,6 @@ | |||
| 485 | 485 | ) | |
| 486 | 486 | } | |
| 487 | 487 | ||
| 488 | - | /// Render the use cases page. | |
| 489 | - | #[tracing::instrument(skip_all, name = "landing::use_cases_page")] | |
| 490 | - | pub(super) async fn use_cases_page( | |
| 491 | - | State(payments): State<Billing>, | |
| 492 | - | session: Session, | |
| 493 | - | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 494 | - | ) -> impl IntoResponse { | |
| 495 | - | UseCasesTemplate { | |
| 496 | - | csrf_token: get_csrf_token(&session).await, | |
| 497 | - | session_user: maybe_user, | |
| 498 | - | tier_prices: payments.tier_prices.clone(), | |
| 499 | - | } | |
| 500 | - | } | |
| 501 | - | ||
| 502 | 488 | /// Render the content policy page. | |
| 503 | 489 | #[tracing::instrument(skip_all, name = "landing::policy_page")] | |
| 504 | 490 | pub(super) async fn policy_page( |
| @@ -134,7 +134,6 @@ | |||
| 134 | 134 | .route_get("/receipt/{transaction_id}", get(content::receipt_page)) | |
| 135 | 135 | .route_get("/buy/{item_id}", get(content::buy_page)) | |
| 136 | 136 | .route_get("/checkout/complete", get(landing::checkout_complete)) | |
| 137 | - | .route_get("/use-cases", get(landing::use_cases_page)) | |
| 138 | 137 | .route_get("/policy", get(landing::policy_page)) | |
| 139 | 138 | .route_get("/fan-plus", get(landing::fan_plus_page)) | |
| 140 | 139 | // Landing "notify me". CSRF-protected like the other public forms, and |
| @@ -1,0 +1,442 @@ | |||
| 1 | + | //! The use-cases page at `/use-cases`, described. | |
| 2 | + | //! | |
| 3 | + | //! The second public document, and the first with content worth calling | |
| 4 | + | //! content: nine creator profiles, four platform promises, a call to action and | |
| 5 | + | //! three onward links. It replaces `templates/pages/use_cases.html`, | |
| 6 | + | //! `UseCasesTemplate` and `landing::use_cases_page`. | |
| 7 | + | //! | |
| 8 | + | //! `/team` proved the mount ([`super::Audience::Anyone`], `4239540d`); this one | |
| 9 | + | //! asks whether the vocabulary carries a marketing page. It does, and the two | |
| 10 | + | //! places it pushed back are worth the reader's time. | |
| 11 | + | //! | |
| 12 | + | //! # A card with a bullet list is a region, not a row | |
| 13 | + | //! | |
| 14 | + | //! `Row` refuses to hold a node, deliberately: "a row part may not carry an | |
| 15 | + | //! arbitrary node, which is the door through which a description becomes a | |
| 16 | + | //! templating language" (`layout::RowPart::Proportion`). Each use-case card has | |
| 17 | + | //! a four-to-five item feature list inside it, so a card is a | |
| 18 | + | //! [`RegionKind::Group`] labelled with its title, and the features are a | |
| 19 | + | //! [`Node::list`] of rows carrying nothing but a primary. | |
| 20 | + | //! | |
| 21 | + | //! The four "Everyone gets" cards have a name and a sentence and nothing else, | |
| 22 | + | //! so those *are* rows, and they are one `Node::list` rather than four regions. | |
| 23 | + | //! Same page, two shapes, and the difference is whether the card contains a | |
| 24 | + | //! structure or a sentence. | |
| 25 | + | //! | |
| 26 | + | //! # The grid is not described, and that is the point | |
| 27 | + | //! | |
| 28 | + | //! `use-case-grid` was `display: grid` with a column count. There is no grid | |
| 29 | + | //! region and there should not be: `RegionKind::Columns` is a kanban of peers | |
| 30 | + | //! that choose nothing about each other, which is a different claim. Nine | |
| 31 | + | //! sibling groups say what is true -- nine cards, equal, belonging to one | |
| 32 | + | //! section -- and how they sit is the design system's answer for every consumer | |
| 33 | + | //! rather than this page's CSS. Same call as `/team`'s single-card grid. | |
| 34 | + | //! | |
| 35 | + | //! # The prices are read per request, from the same place the calculator reads | |
| 36 | + | //! | |
| 37 | + | //! Every tier line interpolates [`crate::tier_prices::TierPrices`], which is | |
| 38 | + | //! `Billing`'s and is derived from `docs/business/assumptions.toml`. The screen | |
| 39 | + | //! reads it off `viewer.app` rather than taking a state of its own: a public | |
| 40 | + | //! document already has a per-request viewer, and a second copy of the prices | |
| 41 | + | //! is a second thing to keep in step with the calculator. | |
| 42 | + | ||
| 43 | + | use makeover_layout as layout; | |
| 44 | + | use quasi_router::screen::Row; | |
| 45 | + | use quasi_router::{ | |
| 46 | + | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 47 | + | }; | |
| 48 | + | use quasi_webview::Webview; | |
| 49 | + | ||
| 50 | + | use crate::tier_prices::TierPrices; | |
| 51 | + | ||
| 52 | + | /// The address, registered whole. See [`super::public_document_mount`]. | |
| 53 | + | pub const PATH: &str = "/use-cases"; | |
| 54 | + | ||
| 55 | + | /// The page's own region, and what the skip link points at. | |
| 56 | + | pub const PAGE_REGION: &str = "use-cases"; | |
| 57 | + | ||
| 58 | + | /// The nine profiles. | |
| 59 | + | const PROFILES: &str = "use-case-profiles"; | |
| 60 | + | ||
| 61 | + | /// What every tier includes. | |
| 62 | + | const UNIVERSAL: &str = "everyone-gets"; | |
| 63 | + | ||
| 64 | + | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 65 | + | ||
| 66 | + | /// One creator profile. | |
| 67 | + | /// | |
| 68 | + | /// `anchor` is the template's `id=`, kept because the marketing site links to | |
| 69 | + | /// these cards directly (`/use-cases#podcasters`) and a conversion that drops | |
| 70 | + | /// the id breaks somebody else's link rather than this page. | |
| 71 | + | struct Profile { | |
| 72 | + | anchor: &'static str, | |
| 73 | + | title: &'static str, | |
| 74 | + | who: &'static str, | |
| 75 | + | description: &'static str, | |
| 76 | + | features: &'static [&'static str], | |
| 77 | + | /// Which tier line the card ends with. See [`Priced`]. | |
| 78 | + | priced: Priced, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// How a card states its price. | |
| 82 | + | /// | |
| 83 | + | /// The template wrote three shapes of tier line by hand, and one of them | |
| 84 | + | /// (Educators) names two tiers. Naming the shape rather than the string is what | |
| 85 | + | /// keeps every price on this page coming from `TierPrices` -- a formatted string | |
| 86 | + | /// in the table here would be a fourth place a price can go stale. | |
| 87 | + | #[derive(Clone, Copy)] | |
| 88 | + | enum Priced { | |
| 89 | + | Basic, | |
| 90 | + | SmallFiles, | |
| 91 | + | BigFiles, | |
| 92 | + | /// "Basic $X/mo or Small Files $Y/mo", which is Educators and only | |
| 93 | + | /// Educators: the work fits in either tier depending on what is uploaded. | |
| 94 | + | BasicOrSmallFiles, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | impl Priced { | |
| 98 | + | /// The line the card ends with, built from the live prices. | |
| 99 | + | fn line(self, prices: &TierPrices) -> String { | |
| 100 | + | match self { | |
| 101 | + | Self::Basic => format!( | |
| 102 | + | "Basic ${}/mo · {}, {}/file", | |
| 103 | + | prices.basic_std, prices.basic_total, prices.basic_per_file | |
| 104 | + | ), | |
| 105 | + | Self::SmallFiles => format!( | |
| 106 | + | "Small Files ${}/mo · {}, {}/file", | |
| 107 | + | prices.small_files_std, prices.small_files_total, prices.small_files_per_file | |
| 108 | + | ), | |
| 109 | + | Self::BigFiles => format!( | |
| 110 | + | "Big Files ${}/mo · {}, {}/file", | |
| 111 | + | prices.big_files_std, prices.big_files_total, prices.big_files_per_file | |
| 112 | + | ), | |
| 113 | + | Self::BasicOrSmallFiles => format!( | |
| 114 | + | "Basic ${}/mo or Small Files ${}/mo", | |
| 115 | + | prices.basic_std, prices.small_files_std | |
| 116 | + | ), | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// The nine, in the order the template listed them. | |
| 122 | + | const PROFILE_LIST: &[Profile] = &[ | |
| 123 | + | Profile { | |
| 124 | + | anchor: "musicians", | |
| 125 | + | title: "Musicians", | |
| 126 | + | who: "Bands, solo artists, producers", | |
| 127 | + | description: "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.", | |
| 128 | + | features: &[ | |
| 129 | + | "MP3, FLAC, WAV, OGG, AAC, AIFF upload", | |
| 130 | + | "In-browser audio player with chapters", | |
| 131 | + | "Cover art and rich metadata", | |
| 132 | + | "RSS feed per project", | |
| 133 | + | "Pay-what-you-want pricing", | |
| 134 | + | ], | |
| 135 | + | priced: Priced::SmallFiles, | |
| 136 | + | }, | |
| 137 | + | Profile { | |
| 138 | + | anchor: "podcasters", | |
| 139 | + | title: "Podcasters", | |
| 140 | + | who: "Independent shows, networks", | |
| 141 | + | description: "Host and distribute your podcast with full RSS support for every major directory.", | |
| 142 | + | features: &[ | |
| 143 | + | "RSS for Apple, Spotify, Pocket Casts", | |
| 144 | + | "Chapter markers", | |
| 145 | + | "Subscriber-only feeds", | |
| 146 | + | "Broadcast emails to followers", | |
| 147 | + | ], | |
| 148 | + | priced: Priced::SmallFiles, | |
| 149 | + | }, | |
| 150 | + | Profile { | |
| 151 | + | anchor: "writers", | |
| 152 | + | title: "Writers", | |
| 153 | + | who: "Bloggers, newsletter writers, authors", | |
| 154 | + | description: "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.", | |
| 155 | + | features: &[ | |
| 156 | + | "Markdown editor with formatting", | |
| 157 | + | "Per-project blog", | |
| 158 | + | "RSS feed", | |
| 159 | + | "Scheduled publishing", | |
| 160 | + | "Broadcast emails", | |
| 161 | + | ], | |
| 162 | + | priced: Priced::Basic, | |
| 163 | + | }, | |
| 164 | + | Profile { | |
| 165 | + | anchor: "developers", | |
| 166 | + | title: "Software Developers", | |
| 167 | + | who: "Indie devs, tool makers, plugin authors", | |
| 168 | + | description: "Distribute software with versioned releases, license keys, and a built-in git browser.", | |
| 169 | + | features: &[ | |
| 170 | + | "Versioned releases with changelogs", | |
| 171 | + | "License keys with activation tracking", | |
| 172 | + | "Git source browser", | |
| 173 | + | "Promo codes and discounts", | |
| 174 | + | ], | |
| 175 | + | priced: Priced::SmallFiles, | |
| 176 | + | }, | |
| 177 | + | Profile { | |
| 178 | + | anchor: "sample-packs", | |
| 179 | + | title: "Sample Packs", | |
| 180 | + | who: "Sound designers, beat makers, foley artists", | |
| 181 | + | description: "Sell sample packs and sound effects with audio previews and versioned downloads.", | |
| 182 | + | features: &[ | |
| 183 | + | "Audio previews in-browser", | |
| 184 | + | "Versioned downloads", | |
| 185 | + | "Hierarchical tags for organization", | |
| 186 | + | "Pay-what-you-want pricing", | |
| 187 | + | "Cover art", | |
| 188 | + | ], | |
| 189 | + | priced: Priced::SmallFiles, | |
| 190 | + | }, | |
| 191 | + | Profile { | |
| 192 | + | anchor: "digital-art", | |
| 193 | + | title: "Digital Art & Photography", | |
| 194 | + | who: "Illustrators, photographers, font designers", | |
| 195 | + | description: "Sell digital art, photography, and fonts with versioned releases and subscription tiers.", | |
| 196 | + | features: &[ | |
| 197 | + | "Any file type supported", | |
| 198 | + | "Versioned releases", | |
| 199 | + | "Subscription tiers", | |
| 200 | + | "Cover images", | |
| 201 | + | ], | |
| 202 | + | priced: Priced::SmallFiles, | |
| 203 | + | }, | |
| 204 | + | Profile { | |
| 205 | + | anchor: "educators", | |
| 206 | + | title: "Educators", | |
| 207 | + | who: "Course creators, tutors, workshop leaders", | |
| 208 | + | description: "Publish courses and teaching materials with text content, downloads, and subscriptions.", | |
| 209 | + | features: &[ | |
| 210 | + | "Text content with Markdown", | |
| 211 | + | "Downloadable materials", | |
| 212 | + | "Subscription tiers", | |
| 213 | + | "Scheduled publishing", | |
| 214 | + | ], | |
| 215 | + | priced: Priced::BasicOrSmallFiles, | |
| 216 | + | }, | |
| 217 | + | Profile { | |
| 218 | + | anchor: "game-devs", | |
| 219 | + | title: "Game Developers", | |
| 220 | + | who: "Indie games, mods, TTRPG content", | |
| 221 | + | description: "Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.", | |
| 222 | + | features: &[ | |
| 223 | + | "Up to 20GB per file (increase on request)", | |
| 224 | + | "Versioned downloads with changelogs", | |
| 225 | + | "License keys with activation limits", | |
| 226 | + | "Promo codes", | |
| 227 | + | ], | |
| 228 | + | priced: Priced::BigFiles, | |
| 229 | + | }, | |
| 230 | + | Profile { | |
| 231 | + | anchor: "comic-creators", | |
| 232 | + | title: "Comic Creators", | |
| 233 | + | who: "Webcomic artists, graphic novelists", | |
| 234 | + | description: "Sell digital comics and graphic novels with project organization and scheduled publishing.", | |
| 235 | + | features: &[ | |
| 236 | + | "PDF, CBZ, and any file type", | |
| 237 | + | "Project organization", | |
| 238 | + | "Subscriptions", | |
| 239 | + | "Scheduled publishing", | |
| 240 | + | ], | |
| 241 | + | priced: Priced::SmallFiles, | |
| 242 | + | }, | |
| 243 | + | ]; | |
| 244 | + | ||
| 245 | + | /// What every tier includes, name and sentence, in the template's order. | |
| 246 | + | const UNIVERSAL_LIST: &[(&str, &str)] = &[ | |
| 247 | + | ( | |
| 248 | + | "0% platform fee", | |
| 249 | + | "Only ~3% payment processing. No percentage cuts, no revenue sharing.", | |
| 250 | + | ), | |
| 251 | + | ( | |
| 252 | + | "Full data export", | |
| 253 | + | "Download everything anytime. Projects, items, sales, contacts: all yours.", | |
| 254 | + | ), | |
| 255 | + | ( | |
| 256 | + | "No lock-in", | |
| 257 | + | "Month-to-month. Cancel anytime. Take your audience with you.", | |
| 258 | + | ), | |
| 259 | + | ( | |
| 260 | + | "Source available", | |
| 261 | + | "Read the codebase. Verify every claim about privacy and data handling.", | |
| 262 | + | ), | |
| 263 | + | ]; | |
| 264 | + | ||
| 265 | + | /// Where the page sends a reader who is convinced, and where it sends one who | |
| 266 | + | /// is not yet. | |
| 267 | + | const ONWARD: &[(&str, &str)] = &[ | |
| 268 | + | ("Pricing Calculator", "/pricing"), | |
| 269 | + | ("Browse as guest", "/discover"), | |
| 270 | + | ("Creator Guide", "/docs/getting-started"), | |
| 271 | + | ]; | |
| 272 | + | ||
| 273 | + | /// The page. Reads no database, only the prices already in memory. | |
| 274 | + | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 275 | + | use axum::extract::FromRef as _; | |
| 276 | + | ||
| 277 | + | let billing = crate::Billing::from_ref(&viewer.app); | |
| 278 | + | Ok(page_screen(&billing.tier_prices).into()) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// The whole document: the title, the measure, the body. | |
| 282 | + | fn page_screen(prices: &TierPrices) -> Described { | |
| 283 | + | let mut profiles = Slot::new(PROFILES, RegionKind::Group); | |
| 284 | + | for profile in PROFILE_LIST { | |
| 285 | + | profiles = profiles.with(Node::Region(card(profile, prices))); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | let universal = Node::list( | |
| 289 | + | UNIVERSAL_LIST | |
| 290 | + | .iter() | |
| 291 | + | .map(|(name, description)| Row::new(*name).secondary(*description)), | |
| 292 | + | ); | |
| 293 | + | ||
| 294 | + | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 295 | + | .with(Node::page("Use Cases")) | |
| 296 | + | .with(Node::text( | |
| 297 | + | "A flat monthly fee. 0% platform cut. Who it's built for:", | |
| 298 | + | )) | |
| 299 | + | .with(Node::section("Available now")) | |
| 300 | + | .with(Node::Region(profiles)) | |
| 301 | + | .with(Node::section("Everyone gets")) | |
| 302 | + | .with(Node::Region( | |
| 303 | + | Slot::new(UNIVERSAL, RegionKind::Group).with(universal), | |
| 304 | + | )) | |
| 305 | + | .with(Node::act( | |
| 306 | + | "Join the Alpha", | |
| 307 | + | Action::get("/join").navigating(), | |
| 308 | + | )); | |
| 309 | + | ||
| 310 | + | for (label, route) in ONWARD { | |
| 311 | + | page = page.with(Node::act(*label, Action::get(*route).navigating())); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | Described::single("Use Cases - Makenotwork") | |
| 315 | + | .measured(MEASURE) | |
| 316 | + | .documented( | |
| 317 | + | Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"])), | |
| 318 | + | ) | |
| 319 | + | .summarised( | |
| 320 | + | "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \ | |
| 321 | + | developers and six more kinds of creator.", | |
| 322 | + | ) | |
| 323 | + | .with(page) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// One profile card: who it is for, what they get, what it costs. | |
| 327 | + | /// | |
| 328 | + | /// The region is identified by the template's anchor so `/use-cases#podcasters` | |
| 329 | + | /// still lands on the right card. | |
| 330 | + | fn card(profile: &Profile, prices: &TierPrices) -> Slot { | |
| 331 | + | let features = Node::list(profile.features.iter().map(|feature| Row::new(*feature))); | |
| 332 | + | ||
| 333 | + | Slot::new(profile.anchor, RegionKind::Group) | |
| 334 | + | .label(profile.title) | |
| 335 | + | .with(Node::text(profile.who)) | |
| 336 | + | .with(Node::text(profile.description)) | |
| 337 | + | .with(features) | |
| 338 | + | .with(Node::text(profile.priced.line(prices))) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | /// The document this screen is drawn in. Same shape as `/team`'s: the skip | |
| 342 | + | /// link and the site header, which reads the viewer's user through an `Option` | |
| 343 | + | /// because on this mount there may not be one. | |
| 344 | + | #[must_use] | |
| 345 | + | pub fn renderer(viewer: &super::Viewer) -> Webview { | |
| 346 | + | Webview::new().with_shell(viewer.document_shell().with_body_first(format!( | |
| 347 | + | "{}{}", | |
| 348 | + | crate::shell::skip_link(PAGE_REGION), | |
| 349 | + | crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)), | |
| 350 | + | ))) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | #[cfg(test)] | |
| 354 | + | mod tests { | |
| 355 | + | use super::*; | |
| 356 | + | ||
| 357 | + | fn prices() -> TierPrices { | |
| 358 | + | TierPrices::default() | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | fn html() -> String { | |
| 362 | + | use quasi_axum::Serves as _; | |
| 363 | + | ||
| 364 | + | Webview::new().screen(&page_screen(&prices())) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | /// `2790e5c4`. The template wrote the measure on the body and | |
| 368 | + | /// `use-cases-page` on a container div; a described document has no | |
| 369 | + | /// container, so both land on the body. Same merge `/team` found. | |
| 370 | + | #[test] | |
| 371 | + | fn the_document_carries_the_classes_the_template_carried() { | |
| 372 | + | let screen = page_screen(&prices()); | |
| 373 | + | ||
| 374 | + | assert_eq!( | |
| 375 | + | screen.document.body_class.as_deref(), | |
| 376 | + | Some("padded-page use-cases-page") | |
| 377 | + | ); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | /// Every card the template drew is still drawn, and still reachable by the | |
| 381 | + | /// anchor the marketing links use. | |
| 382 | + | #[test] | |
| 383 | + | fn every_profile_keeps_its_card_and_its_anchor() { | |
| 384 | + | let html = html(); | |
| 385 | + | ||
| 386 | + | assert_eq!(PROFILE_LIST.len(), 9, "the template drew nine"); | |
| 387 | + | for profile in PROFILE_LIST { | |
| 388 | + | assert!(html.contains(profile.anchor), "{} missing", profile.anchor); | |
| 389 | + | assert!(html.contains(profile.who), "{} missing", profile.who); | |
| 390 | + | for feature in profile.features { | |
| 391 | + | assert!( | |
| 392 | + | html.contains(feature), | |
| 393 | + | "{feature} missing from {}", | |
| 394 | + | profile.title | |
| 395 | + | ); | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | /// No price is written into this module. Each tier line is built from | |
| 401 | + | /// `TierPrices`, so a price change in `assumptions.toml` moves this page | |
| 402 | + | /// with it. | |
| 403 | + | #[test] | |
| 404 | + | fn every_tier_line_comes_from_the_live_prices() { | |
| 405 | + | let mut prices = prices(); | |
| 406 | + | prices.basic_std = 4321; | |
| 407 | + | prices.small_files_std = 5678; | |
| 408 | + | prices.big_files_std = 8765; | |
| 409 | + | ||
| 410 | + | let html = { | |
| 411 | + | use quasi_axum::Serves as _; | |
| 412 | + | Webview::new().screen(&page_screen(&prices)) | |
| 413 | + | }; | |
| 414 | + | ||
| 415 | + | assert!(html.contains("4321"), "the Basic price is not read: {html}"); | |
| 416 | + | assert!(html.contains("5678"), "the Small Files price is not read"); | |
| 417 | + | assert!(html.contains("8765"), "the Big Files price is not read"); | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | /// The educators card is the only one naming two tiers, and it is the case | |
| 421 | + | /// a formatted string in the table would have quietly flattened. | |
| 422 | + | #[test] | |
| 423 | + | fn the_educators_card_names_both_tiers_it_fits_in() { | |
| 424 | + | let mut prices = prices(); | |
| 425 | + | prices.basic_std = 4321; | |
| 426 | + | prices.small_files_std = 5678; | |
| 427 | + | ||
| 428 | + | let line = Priced::BasicOrSmallFiles.line(&prices); | |
| 429 | + | ||
| 430 | + | assert!(line.contains("4321") && line.contains("5678"), "{line}"); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | /// `736f45a5`: this screen's markup carries none of the four spellings. | |
| 434 | + | #[test] | |
| 435 | + | fn the_page_spells_no_spinner() { | |
| 436 | + | let html = html(); | |
| 437 | + | ||
| 438 | + | for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { | |
| 439 | + | assert!(!html.contains(spelling), "{spelling} survives in {html}"); | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | } |
| @@ -1,172 +1,0 @@ | |||
| 1 | - | {% extends "base.html" %} | |
| 2 | - | ||
| 3 | - | {% block title %}Use Cases - Makenotwork{% endblock %} | |
| 4 | - | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }}"{% endblock %} | |
| 5 | - | ||
| 6 | - | {% block content %} | |
| 7 | - | {% include "partials/site_header.html" %} | |
| 8 | - | ||
| 9 | - | <div class="container use-cases-page"> | |
| 10 | - | <h1 class="page-title">Use Cases</h1> | |
| 11 | - | <p class="use-cases-intro">A flat monthly fee. 0% platform cut. Who it's built for:</p> | |
| 12 | - | ||
| 13 | - | <div class="tier-section"> | |
| 14 | - | <h2 class="section-label">Available now</h2> | |
| 15 | - | <div class="use-case-grid"> | |
| 16 | - | <div class="card--bordered use-case-card" id="musicians"> | |
| 17 | - | <div class="use-case-title">Musicians</div> | |
| 18 | - | <div class="use-case-who">Bands, solo artists, producers</div> | |
| 19 | - | <p class="use-case-desc">Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.</p> | |
| 20 | - | <ul class="use-case-features"> | |
| 21 | - | <li>MP3, FLAC, WAV, OGG, AAC, AIFF upload</li> | |
| 22 | - | <li>In-browser audio player with chapters</li> | |
| 23 | - | <li>Cover art and rich metadata</li> | |
| 24 | - | <li>RSS feed per project</li> | |
| 25 | - | <li>Pay-what-you-want pricing</li> | |
| 26 | - | </ul> | |
| 27 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 28 | - | </div> | |
| 29 | - | ||
| 30 | - | <div class="card--bordered use-case-card" id="podcasters"> | |
| 31 | - | <div class="use-case-title">Podcasters</div> | |
| 32 | - | <div class="use-case-who">Independent shows, networks</div> | |
| 33 | - | <p class="use-case-desc">Host and distribute your podcast with full RSS support for every major directory.</p> | |
| 34 | - | <ul class="use-case-features"> | |
| 35 | - | <li>RSS for Apple, Spotify, Pocket Casts</li> | |
| 36 | - | <li>Chapter markers</li> | |
| 37 | - | <li>Subscriber-only feeds</li> | |
| 38 | - | <li>Broadcast emails to followers</li> | |
| 39 | - | </ul> | |
| 40 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 41 | - | </div> | |
| 42 | - | ||
| 43 | - | <div class="card--bordered use-case-card" id="writers"> | |
| 44 | - | <div class="use-case-title">Writers</div> | |
| 45 | - | <div class="use-case-who">Bloggers, newsletter writers, authors</div> | |
| 46 | - | <p class="use-case-desc">Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.</p> | |
| 47 | - | <ul class="use-case-features"> | |
| 48 | - | <li>Markdown editor with formatting</li> | |
| 49 | - | <li>Per-project blog</li> | |
| 50 | - | <li>RSS feed</li> | |
| 51 | - | <li>Scheduled publishing</li> | |
| 52 | - | <li>Broadcast emails</li> | |
| 53 | - | </ul> | |
| 54 | - | <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo · {{ tier_prices.basic_total }}, {{ tier_prices.basic_per_file }}/file</div> | |
| 55 | - | </div> | |
| 56 | - | ||
| 57 | - | <div class="card--bordered use-case-card" id="developers"> | |
| 58 | - | <div class="use-case-title">Software Developers</div> | |
| 59 | - | <div class="use-case-who">Indie devs, tool makers, plugin authors</div> | |
| 60 | - | <p class="use-case-desc">Distribute software with versioned releases, license keys, and a built-in git browser.</p> | |
| 61 | - | <ul class="use-case-features"> | |
| 62 | - | <li>Versioned releases with changelogs</li> | |
| 63 | - | <li>License keys with activation tracking</li> | |
| 64 | - | <li>Git source browser</li> | |
| 65 | - | <li>Promo codes and discounts</li> | |
| 66 | - | </ul> | |
| 67 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 68 | - | </div> | |
| 69 | - | ||
| 70 | - | <div class="card--bordered use-case-card" id="sample-packs"> | |
| 71 | - | <div class="use-case-title">Sample Packs</div> | |
| 72 | - | <div class="use-case-who">Sound designers, beat makers, foley artists</div> | |
| 73 | - | <p class="use-case-desc">Sell sample packs and sound effects with audio previews and versioned downloads.</p> | |
| 74 | - | <ul class="use-case-features"> | |
| 75 | - | <li>Audio previews in-browser</li> | |
| 76 | - | <li>Versioned downloads</li> | |
| 77 | - | <li>Hierarchical tags for organization</li> | |
| 78 | - | <li>Pay-what-you-want pricing</li> | |
| 79 | - | <li>Cover art</li> | |
| 80 | - | </ul> | |
| 81 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 82 | - | </div> | |
| 83 | - | ||
| 84 | - | <div class="card--bordered use-case-card" id="digital-art"> | |
| 85 | - | <div class="use-case-title">Digital Art & Photography</div> | |
| 86 | - | <div class="use-case-who">Illustrators, photographers, font designers</div> | |
| 87 | - | <p class="use-case-desc">Sell digital art, photography, and fonts with versioned releases and subscription tiers.</p> | |
| 88 | - | <ul class="use-case-features"> | |
| 89 | - | <li>Any file type supported</li> | |
| 90 | - | <li>Versioned releases</li> | |
| 91 | - | <li>Subscription tiers</li> | |
| 92 | - | <li>Cover images</li> | |
| 93 | - | </ul> | |
| 94 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 95 | - | </div> | |
| 96 | - | ||
| 97 | - | <div class="card--bordered use-case-card" id="educators"> | |
| 98 | - | <div class="use-case-title">Educators</div> | |
| 99 | - | <div class="use-case-who">Course creators, tutors, workshop leaders</div> | |
| 100 | - | <p class="use-case-desc">Publish courses and teaching materials with text content, downloads, and subscriptions.</p> | |
| 101 | - | <ul class="use-case-features"> | |
| 102 | - | <li>Text content with Markdown</li> | |
| 103 | - | <li>Downloadable materials</li> | |
| 104 | - | <li>Subscription tiers</li> | |
| 105 | - | <li>Scheduled publishing</li> | |
| 106 | - | </ul> | |
| 107 | - | <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo or Small Files ${{ tier_prices.small_files_std }}/mo</div> | |
| 108 | - | </div> | |
| 109 | - | ||
| 110 | - | <div class="card--bordered use-case-card" id="game-devs"> | |
| 111 | - | <div class="use-case-title">Game Developers</div> | |
| 112 | - | <div class="use-case-who">Indie games, mods, TTRPG content</div> | |
| 113 | - | <p class="use-case-desc">Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.</p> | |
| 114 | - | <ul class="use-case-features"> | |
| 115 | - | <li>Up to 20GB per file (increase on request)</li> | |
| 116 | - | <li>Versioned downloads with changelogs</li> | |
| 117 | - | <li>License keys with activation limits</li> | |
| 118 | - | <li>Promo codes</li> | |
| 119 | - | </ul> | |
| 120 | - | <div class="use-case-tier">Big Files ${{ tier_prices.big_files_std }}/mo · {{ tier_prices.big_files_total }}, {{ tier_prices.big_files_per_file }}/file</div> | |
| 121 | - | </div> | |
| 122 | - | ||
| 123 | - | <div class="card--bordered use-case-card" id="comic-creators"> | |
| 124 | - | <div class="use-case-title">Comic Creators</div> | |
| 125 | - | <div class="use-case-who">Webcomic artists, graphic novelists</div> | |
| 126 | - | <p class="use-case-desc">Sell digital comics and graphic novels with project organization and scheduled publishing.</p> | |
| 127 | - | <ul class="use-case-features"> | |
| 128 | - | <li>PDF, CBZ, and any file type</li> | |
| 129 | - | <li>Project organization</li> | |
| 130 | - | <li>Subscriptions</li> | |
| 131 | - | <li>Scheduled publishing</li> | |
| 132 | - | </ul> | |
| 133 | - | <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo · {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div> | |
| 134 | - | </div> | |
| 135 | - | </div> | |
| 136 | - | </div> | |
| 137 | - | ||
| 138 | - | <div class="tier-section"> | |
| 139 | - | <h2 class="section-label">Everyone gets</h2> | |
| 140 | - | <div class="feature-grid"> | |
| 141 | - | <div class="card--bordered"> | |
| 142 | - | <div class="feature-name">0% platform fee</div> | |
| 143 | - | <div class="feature-desc">Only ~3% payment processing. No percentage cuts, no revenue sharing.</div> | |
| 144 | - | </div> | |
| 145 | - | <div class="card--bordered"> | |
| 146 | - | <div class="feature-name">Full data export</div> | |
| 147 | - | <div class="feature-desc">Download everything anytime. Projects, items, sales, contacts: all yours.</div> | |
| 148 | - | </div> | |
| 149 | - | <div class="card--bordered"> | |
| 150 | - | <div class="feature-name">No lock-in</div> | |
| 151 | - | <div class="feature-desc">Month-to-month. Cancel anytime. Take your audience with you.</div> | |
| 152 | - | </div> | |
| 153 | - | <div class="card--bordered"> | |
| 154 | - | <div class="feature-name">Source available</div> | |
| 155 | - | <div class="feature-desc">Read the codebase. Verify every claim about privacy and data handling.</div> | |
| 156 | - | </div> | |
| 157 | - | </div> | |
| 158 | - | </div> | |
| 159 | - | ||
| 160 | - | <div class="landing-cta"> | |
| 161 | - | <a class="btn-primary btn--large" href="/join">Join the Alpha</a> | |
| 162 | - | </div> | |
| 163 | - | ||
| 164 | - | <div class="tier-section"> | |
| 165 | - | <div class="explore-links"> | |
| 166 | - | <a href="/pricing">Pricing Calculator</a> | |
| 167 | - | <a href="/discover">Browse as guest</a> | |
| 168 | - | <a href="/docs/getting-started">Creator Guide</a> | |
| 169 | - | </div> | |
| 170 | - | </div> | |
| 171 | - | </div> | |
| 172 | - | {% endblock %} |