|
1 |
+ |
//! The creator-application page at `/creators`, described.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! The fifth public document. It replaces `templates/pages/creators.html`,
|
|
4 |
+ |
//! `CreatorsTemplate` and `pages::creators_page`.
|
|
5 |
+ |
//!
|
|
6 |
+ |
//! # The tier table is a table, and it is the first described one on a public page
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! Four tiers by four columns, and every cell in two of those columns comes
|
|
9 |
+ |
//! from [`TierPrices`](crate::tier_prices::TierPrices). `Node::Table` says it,
|
|
10 |
+ |
//! the same member `/feed` uses for its item list, so the widths and the
|
|
11 |
+ |
//! narrow-viewport behaviour are the design system's rather than
|
|
12 |
+ |
//! `.wave-table`'s.
|
|
13 |
+ |
//!
|
|
14 |
+ |
//! Where `/use-cases` said the same prices as prose inside nine cards, this
|
|
15 |
+ |
//! says them as a grid, which is what the shipped page did too. Both read the
|
|
16 |
+ |
//! one `TierPrices`, so the two pages cannot disagree about what Basic costs.
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! # Three readers again, and the third one is new
|
|
19 |
+ |
//!
|
|
20 |
+ |
//! [`super::Audience::Anyone`] carries a fourth kind of branch here. `/fan-plus`
|
|
21 |
+ |
//! split on whether the reader had bought; this splits on what the reader is
|
|
22 |
+ |
//! allowed to do:
|
|
23 |
+ |
//!
|
|
24 |
+ |
//! a visitor Join, and Login
|
|
25 |
+ |
//! a reader Apply, from the dashboard
|
|
26 |
+ |
//! a creator nothing to apply for; go to the dashboard
|
|
27 |
+ |
//!
|
|
28 |
+ |
//! `can_create_projects` is the flag, read off the session user the factory
|
|
29 |
+ |
//! already resolved, so the branch costs no query.
|
|
30 |
+ |
//!
|
|
31 |
+ |
//! # The count is live and the page says so
|
|
32 |
+ |
//!
|
|
33 |
+ |
//! `total_creators` is read at request time. It is the one number on this page
|
|
34 |
+ |
//! that is not a price, and the disclosure is the point: a person deciding
|
|
35 |
+ |
//! whether to apply is told how many creators are actually here.
|
|
36 |
+ |
|
|
37 |
+ |
use makeover_layout as layout;
|
|
38 |
+ |
use quasi_router::screen::{Cell, Cells, Column, Figure};
|
|
39 |
+ |
use quasi_router::{
|
|
40 |
+ |
Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
|
|
41 |
+ |
};
|
|
42 |
+ |
use quasi_webview::Webview;
|
|
43 |
+ |
|
|
44 |
+ |
use crate::db;
|
|
45 |
+ |
use crate::tier_prices::TierPrices;
|
|
46 |
+ |
|
|
47 |
+ |
/// The address, registered whole. See [`super::public_document_mount`].
|
|
48 |
+ |
pub const PATH: &str = "/creators";
|
|
49 |
+ |
|
|
50 |
+ |
/// The page's own region, and what the skip link points at.
|
|
51 |
+ |
pub const PAGE_REGION: &str = "creators";
|
|
52 |
+ |
|
|
53 |
+ |
const MEASURE: layout::Measure = layout::Measure::Wide;
|
|
54 |
+ |
|
|
55 |
+ |
/// One row of the tier table.
|
|
56 |
+ |
///
|
|
57 |
+ |
/// `price` and `storage` are read off [`TierPrices`] rather than written here,
|
|
58 |
+ |
/// for the reason `/use-cases` gives: a formatted price in a table is a price
|
|
59 |
+ |
/// that goes stale on its own.
|
|
60 |
+ |
struct Tier {
|
|
61 |
+ |
name: &'static str,
|
|
62 |
+ |
best_for: &'static str,
|
|
63 |
+ |
price: fn(&TierPrices) -> i32,
|
|
64 |
+ |
storage: fn(&TierPrices) -> String,
|
|
65 |
+ |
}
|
|
66 |
+ |
|
|
67 |
+ |
/// The four, in the order the shipped table listed them.
|
|
68 |
+ |
const TIERS: &[Tier] = &[
|
|
69 |
+ |
Tier {
|
|
70 |
+ |
name: "Basic",
|
|
71 |
+ |
best_for: "Text, blogs, newsletters",
|
|
72 |
+ |
price: |p| p.basic_std,
|
|
73 |
+ |
storage: |p| p.basic_total.clone(),
|
|
74 |
+ |
},
|
|
75 |
+ |
Tier {
|
|
76 |
+ |
name: "Small Files",
|
|
77 |
+ |
best_for: "Audio, plugins, small software",
|
|
78 |
+ |
price: |p| p.small_files_std,
|
|
79 |
+ |
storage: |p| p.small_files_total.clone(),
|
|
80 |
+ |
},
|
|
81 |
+ |
Tier {
|
|
82 |
+ |
name: "Big Files",
|
|
83 |
+ |
best_for: "Video, games, large software",
|
|
84 |
+ |
price: |p| p.big_files_std,
|
|
85 |
+ |
storage: |p| p.big_files_total.clone(),
|
|
86 |
+ |
},
|
|
87 |
+ |
Tier {
|
|
88 |
+ |
name: "Everything",
|
|
89 |
+ |
best_for: "All features, current and future",
|
|
90 |
+ |
price: |p| p.everything_std,
|
|
91 |
+ |
storage: |p| p.everything_total.clone(),
|
|
92 |
+ |
},
|
|
93 |
+ |
];
|
|
94 |
+ |
|
|
95 |
+ |
/// What this request knows about the reader's standing.
|
|
96 |
+ |
enum Standing {
|
|
97 |
+ |
/// Nobody signed in.
|
|
98 |
+ |
Visitor,
|
|
99 |
+ |
/// Signed in, not yet a creator.
|
|
100 |
+ |
Reader,
|
|
101 |
+ |
/// Already has creator access.
|
|
102 |
+ |
Creator,
|
|
103 |
+ |
}
|
|
104 |
+ |
|
|
105 |
+ |
/// The page.
|
|
106 |
+ |
pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
|
|
107 |
+ |
use axum::extract::FromRef as _;
|
|
108 |
+ |
|
|
109 |
+ |
let total_creators = viewer
|
|
110 |
+ |
.block_on(db::waitlist::count_active_creators(&viewer.app.db))
|
|
111 |
+ |
.map_err(|_| RouteError::internal("the creator count could not be read"))?;
|
|
112 |
+ |
|
|
113 |
+ |
let standing = match viewer.user.as_ref() {
|
|
114 |
+ |
None => Standing::Visitor,
|
|
115 |
+ |
Some(user) if user.can_create_projects => Standing::Creator,
|
|
116 |
+ |
Some(_) => Standing::Reader,
|
|
117 |
+ |
};
|
|
118 |
+ |
|
|
119 |
+ |
let billing = crate::Billing::from_ref(&viewer.app);
|
|
120 |
+ |
|
|
121 |
+ |
Ok(page_screen(&standing, total_creators, &billing.tier_prices).into())
|
|
122 |
+ |
}
|
|
123 |
+ |
|
|
124 |
+ |
/// The whole document: the title, the measure, the body.
|
|
125 |
+ |
fn page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Described {
|
|
126 |
+ |
let page = Slot::new(PAGE_REGION, RegionKind::Pane)
|
|
127 |
+ |
.with(Node::page("Become a Creator"))
|
|
128 |
+ |
.with(Node::text(
|
|
129 |
+ |
"Anyone can sign up to browse and buy. To create projects and sell your work, apply \
|
|
130 |
+ |
for creator access. Most applications are approved within a few days. Makenotwork is \
|
|
131 |
+ |
in private alpha; we're approving applications one cohort at a time.",
|
|
132 |
+ |
))
|
|
133 |
+ |
.with(Node::section("How It Works"))
|
|
134 |
+ |
.with(Node::rich(
|
|
135 |
+ |
"1. **Sign up** and verify your email\n\
|
|
136 |
+ |
2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
|
|
137 |
+ |
3. **Get approved**: we review applications individually, usually within a few days\n\
|
|
138 |
+ |
\n\
|
|
139 |
+ |
We review applications to make sure applicants are here to share and sell creative \
|
|
140 |
+ |
work. If you make something and want to sell it, you'll likely get in. Link to your \
|
|
141 |
+ |
existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\
|
|
142 |
+ |
\n\
|
|
143 |
+ |
**Important:** You sell in the currency your Stripe account settles in, and \
|
|
144 |
+ |
receiving payouts requires a [Stripe](https://stripe.com/global) account in a \
|
|
145 |
+ |
supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \
|
|
146 |
+ |
NZD or EUR**. Check both with Stripe before applying.",
|
|
147 |
+ |
))
|
|
148 |
+ |
.with(Node::stats([Figure::new(
|
|
149 |
+ |
total_creators.to_string(),
|
|
150 |
+ |
"Active Creators",
|
|
151 |
+ |
)]))
|
|
152 |
+ |
.with(Node::section("Pricing"))
|
|
153 |
+ |
.with(Node::text(
|
|
154 |
+ |
"Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is \
|
|
155 |
+ |
the payment processor's fee (~3%).",
|
|
156 |
+ |
))
|
|
157 |
+ |
.with(tier_table(prices))
|
|
158 |
+ |
.with(Node::rich(
|
|
159 |
+ |
"Every tier is the complete platform: `/u/username` profile, project and item pages, \
|
|
160 |
+ |
project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
|
|
161 |
+ |
analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
|
|
162 |
+ |
not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \
|
|
163 |
+ |
NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \
|
|
164 |
+ |
supported country. [Full tier details](/docs/tiers) | \
|
|
165 |
+ |
[Pricing models](/docs/pricing)",
|
|
166 |
+ |
))
|
|
167 |
+ |
.with(Node::rich(
|
|
168 |
+ |
"**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
|
|
169 |
+ |
you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
|
|
170 |
+ |
up.",
|
|
171 |
+ |
))
|
|
172 |
+ |
.with(Node::section("Who Runs This"))
|
|
173 |
+ |
.with(Node::rich(
|
|
174 |
+ |
"Makenotwork is built and operated by one person. No investors, no board, no outside \
|
|
175 |
+ |
pressure. Decisions are fast and aligned with creators, but there's no large team \
|
|
176 |
+ |
behind the scenes. Read the full picture in our \
|
|
177 |
+ |
[continuity guarantee](/docs/guarantees#continuity) and \
|
|
178 |
+ |
[platform economics](/docs/economics).",
|
|
179 |
+ |
));
|
|
180 |
+ |
|
|
181 |
+ |
let page = call_to_action(page, standing);
|
|
182 |
+ |
|
|
183 |
+ |
Described::single("Creators - Makenotwork")
|
|
184 |
+ |
.measured(MEASURE)
|
|
185 |
+ |
.documented(
|
|
186 |
+ |
Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])),
|
|
187 |
+ |
)
|
|
188 |
+ |
.summarised(
|
|
189 |
+ |
"Apply for creator access: a flat monthly fee, no cut of your revenue, and four \
|
|
190 |
+ |
tiers that pick a file-size envelope rather than a feature set.",
|
|
191 |
+ |
)
|
|
192 |
+ |
.with(page)
|
|
193 |
+ |
}
|
|
194 |
+ |
|
|
195 |
+ |
/// The four tiers, priced from the live figures.
|
|
196 |
+ |
fn tier_table(prices: &TierPrices) -> Node {
|
|
197 |
+ |
Node::Table {
|
|
198 |
+ |
columns: vec![
|
|
199 |
+ |
Column::new("Tier")
|
|
200 |
+ |
.width(layout::Width::Content)
|
|
201 |
+ |
.priority(layout::Priority::Essential),
|
|
202 |
+ |
Column::new("Monthly").width(layout::Width::Content),
|
|
203 |
+ |
Column::new("Best For").width(layout::Width::Fill),
|
|
204 |
+ |
Column::new("Storage").width(layout::Width::Content),
|
|
205 |
+ |
],
|
|
206 |
+ |
rows: TIERS
|
|
207 |
+ |
.iter()
|
|
208 |
+ |
.map(|tier| {
|
|
209 |
+ |
Cells::new([
|
|
210 |
+ |
Cell::new(tier.name),
|
|
211 |
+ |
Cell::new(format!("${}", (tier.price)(prices))),
|
|
212 |
+ |
Cell::new(tier.best_for),
|
|
213 |
+ |
Cell::new((tier.storage)(prices)),
|
|
214 |
+ |
])
|
|
215 |
+ |
})
|
|
216 |
+ |
.collect(),
|
|
217 |
+ |
more: None,
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
|
|
221 |
+ |
/// What the page asks of this reader, which is the only thing on it that
|
|
222 |
+ |
/// differs by who is asking.
|
|
223 |
+ |
fn call_to_action(page: Slot, standing: &Standing) -> Slot {
|
|
224 |
+ |
match standing {
|
|
225 |
+ |
Standing::Creator => page
|
|
226 |
+ |
.with(Node::text("You have creator access."))
|
|
227 |
+ |
.with(Node::act(
|
|
228 |
+ |
"Go to Dashboard",
|
|
229 |
+ |
Action::get("/dashboard").navigating(),
|
|
230 |
+ |
)),
|
|
231 |
+ |
Standing::Reader => page.with(Node::text("Ready to create?")).with(Node::act(
|
|
232 |
+ |
"Apply from Dashboard",
|
|
233 |
+ |
Action::get("/dashboard?tab=settings§ion=creator").navigating(),
|
|
234 |
+ |
)),
|
|
235 |
+ |
Standing::Visitor => page
|
|
236 |
+ |
.with(Node::text("Join to get started."))
|
|
237 |
+ |
.with(Node::act("Join", Action::get("/join").navigating()))
|
|
238 |
+ |
.with(Node::act("Login", Action::get("/login").navigating())),
|
|
239 |
+ |
}
|
|
240 |
+ |
}
|
|
241 |
+ |
|
|
242 |
+ |
/// The document this screen is drawn in.
|
|
243 |
+ |
#[must_use]
|
|
244 |
+ |
pub fn renderer(viewer: &super::Viewer) -> Webview {
|
|
245 |
+ |
Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
|
|
246 |
+ |
"{}{}",
|
|
247 |
+ |
crate::shell::skip_link(PAGE_REGION),
|
|
248 |
+ |
crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
|
|
249 |
+ |
)))
|
|
250 |
+ |
}
|
|
251 |
+ |
|
|
252 |
+ |
#[cfg(test)]
|
|
253 |
+ |
mod tests {
|
|
254 |
+ |
use super::*;
|
|
255 |
+ |
|
|
256 |
+ |
fn html(standing: &Standing) -> String {
|
|
257 |
+ |
use quasi_axum::Serves as _;
|
|
258 |
+ |
|
|
259 |
+ |
Webview::new().screen(&page_screen(standing, 7, &TierPrices::default()))
|
|
260 |
+ |
}
|
|
261 |
+ |
|
|
262 |
+ |
/// `2790e5c4`. Both classes were on the body already, so this is a copy.
|
|
263 |
+ |
#[test]
|
|
264 |
+ |
fn the_document_carries_the_classes_the_template_carried() {
|
|
265 |
+ |
let screen = page_screen(&Standing::Visitor, 0, &TierPrices::default());
|
|
266 |
+ |
|
|
267 |
+ |
assert_eq!(
|
|
268 |
+ |
screen.document.body_class.as_deref(),
|
|
269 |
+ |
Some("padded-page creators-page")
|
|
270 |
+ |
);
|
|
271 |
+ |
}
|
|
272 |
+ |
|
|
273 |
+ |
/// Every tier the table listed is still listed, and its price is read
|
|
274 |
+ |
/// rather than written.
|
|
275 |
+ |
#[test]
|
|
276 |
+ |
fn every_tier_is_priced_from_the_live_figures() {
|
|
277 |
+ |
let mut prices = TierPrices::default();
|
|
278 |
+ |
prices.basic_std = 4321;
|
|
279 |
+ |
prices.small_files_std = 5678;
|
|
280 |
+ |
prices.big_files_std = 8765;
|
|
281 |
+ |
prices.everything_std = 9876;
|
|
282 |
+ |
|
|
283 |
+ |
let html = {
|
|
284 |
+ |
use quasi_axum::Serves as _;
|
|
285 |
+ |
Webview::new().screen(&page_screen(&Standing::Visitor, 0, &prices))
|
|
286 |
+ |
};
|
|
287 |
+ |
|
|
288 |
+ |
assert_eq!(TIERS.len(), 4);
|
|
289 |
+ |
for tier in TIERS {
|
|
290 |
+ |
assert!(html.contains(tier.name), "{} missing", tier.name);
|
|
291 |
+ |
assert!(html.contains(tier.best_for), "{} missing", tier.best_for);
|
|
292 |
+ |
}
|
|
293 |
+ |
for price in ["4321", "5678", "8765", "9876"] {
|
|
294 |
+ |
assert!(html.contains(price), "{price} is not read from TierPrices");
|
|
295 |
+ |
}
|
|
296 |
+ |
}
|
|
297 |
+ |
|
|
298 |
+ |
/// The live disclosure: how many creators are actually here.
|
|
299 |
+ |
#[test]
|
|
300 |
+ |
fn the_active_creator_count_is_shown() {
|
|
301 |
+ |
assert!(html(&Standing::Visitor).contains('7'));
|
|
302 |
+ |
assert!(html(&Standing::Visitor).contains("Active Creators"));
|
|
303 |
+ |
}
|
|
304 |
+ |
|
|
305 |
+ |
/// A visitor is offered an account, not an application they cannot file.
|
|
306 |
+ |
#[test]
|
|
307 |
+ |
fn a_visitor_is_offered_both_ways_in() {
|
|
308 |
+ |
let html = html(&Standing::Visitor);
|
|
309 |
+ |
|
|
310 |
+ |
assert!(html.contains(r#"href="/join""#), "{html}");
|
|
311 |
+ |
assert!(html.contains(r#"href="/login""#), "{html}");
|
|
312 |
+ |
assert!(!html.contains("tab=settings"), "{html}");
|
|
313 |
+ |
}
|
|
314 |
+ |
|
|
315 |
+ |
/// A signed-in reader is sent to the place the application lives.
|
|
316 |
+ |
#[test]
|
|
317 |
+ |
fn a_reader_is_sent_to_the_application() {
|
|
318 |
+ |
let html = html(&Standing::Reader);
|
|
319 |
+ |
|
|
320 |
+ |
assert!(html.contains("section=creator"), "{html}");
|
|
321 |
+ |
assert!(!html.contains(r#"href="/join""#), "{html}");
|
|
322 |
+ |
}
|
|
323 |
+ |
|
|
324 |
+ |
/// A creator is not sold something they already have.
|
|
325 |
+ |
#[test]
|
|
326 |
+ |
fn a_creator_is_offered_the_dashboard_and_no_application() {
|
|
327 |
+ |
let html = html(&Standing::Creator);
|
|
328 |
+ |
|
|
329 |
+ |
assert!(html.contains("You have creator access"), "{html}");
|
|
330 |
+ |
assert!(!html.contains("section=creator"), "{html}");
|
|
331 |
+ |
assert!(!html.contains("Ready to create"), "{html}");
|
|
332 |
+ |
}
|
|
333 |
+ |
|
|
334 |
+ |
/// The payout constraint is the one piece of prose on this page somebody
|
|
335 |
+ |
/// can lose money by not reading, so it keeps its link and its emphasis.
|
|
336 |
+ |
#[test]
|
|
337 |
+ |
fn the_stripe_settlement_warning_survives_intact() {
|
|
338 |
+ |
let html = html(&Standing::Visitor);
|
|
339 |
+ |
|
|
340 |
+ |
assert!(html.contains("https://stripe.com/global"), "{html}");
|
|
341 |
+ |
assert!(
|
|
342 |
+ |
html.contains("USD, CAD, GBP, AUD, NZD or EUR"),
|
|
343 |
+ |
"the six settlement currencies are not stated: {html}"
|
|
344 |
+ |
);
|
|
345 |
+ |
}
|
|
346 |
+ |
|
|
347 |
+ |
/// `736f45a5`: this screen's markup carries none of the four spellings.
|
|
348 |
+ |
#[test]
|
|
349 |
+ |
fn the_page_spells_no_spinner() {
|
|
350 |
+ |
let html = html(&Standing::Visitor);
|
|
351 |
+ |
|
|
352 |
+ |
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
|
|
353 |
+ |
assert!(!html.contains(spelling), "{spelling} survives in {html}");
|
|
354 |
+ |
}
|
|
355 |
+ |
}
|
|
356 |
+ |
}
|