Skip to main content

max / makenotwork

Collapse join wizard from 5 steps to 3 (v0.5.3) Remove pitch and Stripe steps from signup wizard. Flow is now: Account -> Profile -> Welcome. Welcome page branches by intent: "Browse and buy" links to /discover, "I want to sell" links to dashboard Creator Plan tab where pitch already lives. Invited users and existing creators see contextual CTAs. Stripe setup is just-in-time (dashboard Payments tab, not signup). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 22:30 UTC
Commit: ef825eb4c3133fa4cc5997ce031dd50180858af4
Parent: 9891861
9 files changed, +60 insertions, -140 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.2"
3 + version = "0.5.3"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -30,16 +30,9 @@ Creators with 20+ items need these. One-at-a-time editing doesn't scale.
30 30 - [x] Bulk delete now soft-deletes (sets deleted_at + is_public=false, scheduler purges after 7 days)
31 31 - [ ] Add global search across all projects and items from dashboard
32 32
33 - ## Sprint 3: Onboarding Overhaul
33 + ## Sprint 3: Onboarding Overhaul — DONE
34 34
35 - The join wizard conflates buyer and creator journeys. This sprint simplifies it so fans don't bounce through creator steps and creators don't skip things they need.
36 -
37 - - [ ] Branch signup by intent — ask "browse or sell?" after account creation
38 - - [ ] Collapse from 5 steps to 3 (account, profile, welcome)
39 - - [ ] Move creator pitch to dashboard (not a signup step)
40 - - [ ] Move Stripe setup to just-in-time (prompt at first publish)
41 - - [ ] Rename "Apply as Creator" to "Tell us about your work"
42 - - [ ] Rename "Pitch" — term is never defined for users
35 + Join wizard collapsed from 5 steps to 3 (Account, Profile, Welcome). Pitch and Stripe removed from wizard — pitch lives on dashboard Creator Plan tab, Stripe on Payments tab. Welcome page branches by intent: "Browse and buy" vs "I want to sell" with contextual CTAs for invited users and existing creators.
43 36
44 37 ## Sprint 4: Dashboard Polish
45 38
@@ -21,12 +21,11 @@ use crate::{
21 21 helpers::{get_csrf_token, is_htmx_request},
22 22 routes::pages::dashboard::wizards::build_step_nav,
23 23 templates::*,
24 - validation,
25 24 AppState,
26 25 };
27 26
28 - const JOIN_STEPS: &[&str] = &["account", "profile", "pitch", "stripe", "complete"];
29 - const JOIN_LABELS: &[&str] = &["Account", "Profile", "Creator", "Stripe", "Welcome"];
27 + const JOIN_STEPS: &[&str] = &["account", "profile", "complete"];
28 + const JOIN_LABELS: &[&str] = &["Account", "Profile", "Welcome"];
30 29
31 30 /// Query params for the join page.
32 31 #[derive(Debug, Deserialize)]
@@ -260,31 +259,7 @@ pub async fn step_save(
260 259 )
261 260 .await?;
262 261 }
263 - render_step("pitch", &state, user.id).await
264 - }
265 - "pitch" => {
266 - let pitch_text = form_data.get("pitch").cloned().unwrap_or_default();
267 - let pitch_trimmed = pitch_text.trim();
268 - if !pitch_trimmed.is_empty() {
269 - validation::validate_waitlist_pitch(pitch_trimmed)?;
270 -
271 - // Build full application text with optional fields appended
272 - let mut full_pitch = pitch_trimmed.to_string();
273 - if let Some(tier) = form_data.get("preferred_tier").filter(|s| !s.trim().is_empty()) {
274 - full_pitch.push_str(&format!("\n\n[Preferred tier: {}]", tier.trim()));
275 - }
276 - if form_data.get("free_trial").map(|s| s.as_str()) == Some("yes") {
277 - let length = form_data.get("trial_length").map(|s| s.trim().to_string()).unwrap_or_else(|| "not specified".to_string());
278 - let reason = form_data.get("trial_reason").map(|s| s.trim().to_string()).unwrap_or_default();
279 - full_pitch.push_str(&format!("\n\n[Free trial requested: {}]", length));
280 - if !reason.is_empty() {
281 - full_pitch.push_str(&format!("\n[Trial reason: {}]", reason));
282 - }
283 - }
284 -
285 - db::waitlist::create_waitlist_entry(&state.db, user.id, &full_pitch).await?;
286 - }
287 - render_step("stripe", &state, user.id).await
262 + render_step("complete", &state, user.id).await
288 263 }
289 264 _ => Err(AppError::NotFound),
290 265 }
@@ -310,48 +285,23 @@ async fn render_step(step: &str, state: &AppState, user_id: db::UserId) -> Resul
310 285 .into_response())
311 286 }
312 287 "profile" => Ok(render_step_profile()),
313 - "pitch" => {
314 - // Auto-skip if email not verified, already a creator, or already has waitlist entry
288 + "complete" => {
315 289 let user = db::users::get_user_by_id(&state.db, user_id)
316 290 .await?
317 291 .ok_or(AppError::NotFound)?;
318 - let has_waitlist = db::waitlist::get_waitlist_entry_by_user(&state.db, user_id)
292 + let has_invite = db::waitlist::get_waitlist_entry_by_user(&state.db, user_id)
319 293 .await?
320 294 .is_some();
321 - if !user.email_verified || user.can_create_projects || has_waitlist {
322 - return render_step_stripe(state, user_id).await;
323 - }
324 - Ok(WizardJoinPitchTemplate {
325 - nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "pitch"),
326 - csrf_token: None,
327 - }
328 - .into_response())
329 - }
330 - "stripe" => render_step_stripe(state, user_id).await,
331 - "complete" => {
332 - let user = db::users::get_user_by_id(&state.db, user_id)
333 - .await?
334 - .ok_or(AppError::NotFound)?;
335 295 Ok(WizardJoinCompleteTemplate {
336 296 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "complete"),
337 297 display_name: user
338 298 .display_name
339 299 .unwrap_or_else(|| user.username.to_string()),
300 + is_creator: user.can_create_projects,
301 + has_invite,
340 302 }
341 303 .into_response())
342 304 }
343 305 _ => Err(AppError::NotFound),
344 306 }
345 307 }
346 -
347 - /// Render the stripe step (extracted to avoid recursion in render_step).
348 - async fn render_step_stripe(state: &AppState, user_id: db::UserId) -> Result<Response> {
349 - let user = db::users::get_user_by_id(&state.db, user_id)
350 - .await?
351 - .ok_or(AppError::NotFound)?;
352 - Ok(WizardJoinStripeTemplate {
353 - nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "stripe"),
354 - stripe_connected: user.stripe_account_id.is_some(),
355 - }
356 - .into_response())
357 - }
@@ -210,8 +210,6 @@ impl_into_response!(
210 210 WizardJoinTemplate,
211 211 WizardJoinAccountTemplate,
212 212 WizardJoinProfileTemplate,
213 - WizardJoinPitchTemplate,
214 - WizardJoinStripeTemplate,
215 213 WizardJoinCompleteTemplate,
216 214 // Creation wizards — full pages
217 215 WizardProjectTemplate,
@@ -111,28 +111,16 @@ pub struct WizardJoinProfileTemplate {
111 111 pub nav: Vec<super::StepNavItem>,
112 112 }
113 113
114 - /// Step 3 partial: creator pitch.
115 - #[derive(Template)]
116 - #[template(path = "wizards/steps/join/pitch.html")]
117 - pub struct WizardJoinPitchTemplate {
118 - pub nav: Vec<super::StepNavItem>,
119 - pub csrf_token: CsrfTokenOption,
120 - }
121 -
122 - /// Step 4 partial: Stripe connect.
123 - #[derive(Template)]
124 - #[template(path = "wizards/steps/join/stripe.html")]
125 - pub struct WizardJoinStripeTemplate {
126 - pub nav: Vec<super::StepNavItem>,
127 - pub stripe_connected: bool,
128 - }
129 -
130 - /// Step 5 partial: welcome/complete.
114 + /// Step 3 partial: welcome/complete with intent branching.
131 115 #[derive(Template)]
132 116 #[template(path = "wizards/steps/join/complete.html")]
133 117 pub struct WizardJoinCompleteTemplate {
134 118 pub nav: Vec<super::StepNavItem>,
135 119 pub display_name: String,
120 + /// Whether this user already has creator access.
121 + pub is_creator: bool,
122 + /// Whether this user arrived via invite (already has waitlist entry).
123 + pub has_invite: bool,
136 124 }
137 125
138 126 /// Two-factor authentication verification page (login flow).
@@ -3,39 +3,41 @@
3 3 <div class="wizard-step">
4 4 <h2>Welcome, {{ display_name }}<span class="dot">.</span></h2>
5 5
6 - <div class="info-box" style="margin: 1.5rem 0;">
7 - <p>Your account is ready. You can browse and buy now.</p>
8 - <p style="margin-top: 0.5rem;">To sell content, apply for creator access from your <a href="/dashboard#tab-plan">Dashboard</a>. Most applications are approved within a few days.</p>
9 - </div>
6 + <p class="step-description" style="margin-bottom: 2rem;">Your account is ready. What brings you to Makenot.work?</p>
10 7
11 - <div class="tier-section">
12 - <h2 class="section-label">Creator tiers</h2>
13 - <div class="tier-grid">
14 - <div class="tier-card">
15 - <div class="tier-name">Basic</div>
16 - <div class="tier-price">$10/mo</div>
17 - <div class="tier-desc">Text, blogs, newsletters. 50GB storage, 10MB/file.</div>
8 + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; max-width: 500px;">
9 + <a href="/discover" style="text-decoration: none; color: inherit;">
10 + <div style="background: var(--surface-muted); padding: 1.5rem; text-align: center; border: 2px solid transparent; transition: border-color 0.15s;"
11 + onmouseover="this.style.borderColor='var(--detail)'" onmouseout="this.style.borderColor='transparent'">
12 + <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.1rem; margin-bottom: 0.5rem;">Browse and buy</div>
13 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0;">Discover music, software, and digital content from independent creators.</p>
18 14 </div>
19 - <div class="tier-card">
20 - <div class="tier-name">Small Files</div>
21 - <div class="tier-price">$20/mo</div>
22 - <div class="tier-desc">Audio, software, plugins. 250GB storage, 500MB/file.</div>
15 + </a>
16 +
17 + {% if is_creator %}
18 + <a href="/dashboard" style="text-decoration: none; color: inherit;">
19 + <div style="background: var(--surface-muted); padding: 1.5rem; text-align: center; border: 2px solid var(--detail);">
20 + <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.1rem; margin-bottom: 0.5rem;">Start creating</div>
21 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0;">You have creator access. Set up your first project and start selling.</p>
23 22 </div>
24 - <div class="tier-card">
25 - <div class="tier-name">Big Files</div>
26 - <div class="tier-price">$30/mo</div>
27 - <div class="tier-desc">Video, games, large software. 500GB storage, 20GB/file.</div>
23 + </a>
24 + {% else if has_invite %}
25 + <a href="/dashboard" style="text-decoration: none; color: inherit;">
26 + <div style="background: var(--surface-muted); padding: 1.5rem; text-align: center; border: 2px solid var(--detail);">
27 + <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.1rem; margin-bottom: 0.5rem;">Start creating</div>
28 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0;">You were invited. Head to your dashboard to set up your first project.</p>
28 29 </div>
29 - <div class="tier-card">
30 - <div class="tier-name">Everything</div>
31 - <div class="tier-price">$60/mo</div>
32 - <div class="tier-desc">Live streaming, all features, current and future. 500GB storage, 20GB/file.</div>
30 + </a>
31 + {% else %}
32 + <a href="/dashboard#tab-plan" style="text-decoration: none; color: inherit;">
33 + <div style="background: var(--surface-muted); padding: 1.5rem; text-align: center; border: 2px solid transparent; transition: border-color 0.15s;"
34 + onmouseover="this.style.borderColor='var(--detail)'" onmouseout="this.style.borderColor='transparent'">
35 + <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.1rem; margin-bottom: 0.5rem;">I want to sell</div>
36 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0;">Apply for creator access. 0% platform fee — only Stripe's ~3% processing.</p>
33 37 </div>
34 - </div>
38 + </a>
39 + {% endif %}
35 40 </div>
36 41
37 - <div class="wizard-actions" style="margin-top: 1.5rem;">
38 - <a href="/discover" class="secondary">Browse</a>
39 - <a href="/dashboard" class="primary">Dashboard</a>
40 - </div>
42 + <p style="font-size: 0.85rem; opacity: 0.5; margin-top: 1.5rem; text-align: center;">You can always switch later from your dashboard.</p>
41 43 </div>
@@ -21,7 +21,7 @@
21 21
22 22 <div class="wizard-actions">
23 23 <button type="button" class="secondary"
24 - hx-get="/join/step/pitch"
24 + hx-get="/join/step/complete"
25 25 hx-target="#wizard-step" hx-swap="innerHTML">Skip</button>
26 26 <button type="submit" class="primary">Continue</button>
27 27 </div>
@@ -17,7 +17,7 @@
17 17 <div class="wizard-content" id="wizard-step">
18 18 <div class="wizard-step">
19 19 <h2>Create account</h2>
20 - <p class="step-description">Makenot.work is a 0% fee platform where creators sell directly to fans. Sign up to browse and buy — or apply to sell your own work.</p>
20 + <p class="step-description">Makenot.work is a 0% fee platform where creators sell directly to fans. Sign up to browse, buy, or sell your own work.</p>
21 21
22 22 {% if let Some(code) = invite_code %}
23 23 <div class="info-box" style="margin-bottom: 1rem;">
@@ -675,10 +675,10 @@ async fn join_wizard_full_flow() {
675 675 )
676 676 .await;
677 677 assert!(resp.status.is_success(), "Step 2 failed: {}", resp.text);
678 - // New users don't have verified email, so pitch auto-skips to stripe
678 + // Profile now goes directly to welcome
679 679 assert!(
680 - resp.text.contains("Payment Setup"),
681 - "Should auto-skip pitch to stripe step"
680 + resp.text.contains("Welcome"),
681 + "Should advance to welcome step"
682 682 );
683 683
684 684 // Verify profile saved
@@ -689,19 +689,10 @@ async fn join_wizard_full_flow() {
689 689 .unwrap();
690 690 assert_eq!(display_name.as_deref(), Some("Join Wizard"));
691 691
692 - // Step 4: Stripe — skip to complete
693 - let resp = h
694 - .client
695 - .get("/join/step/complete")
696 - .await;
697 - assert!(resp.status.is_success());
698 - assert!(
699 - resp.text.contains("Welcome"),
700 - "Should show welcome step"
701 - );
692 + // Welcome page should have intent branching
702 693 assert!(
703 - resp.text.contains("Dashboard"),
704 - "Should have dashboard link"
694 + resp.text.contains("Browse and buy") || resp.text.contains("I want to sell"),
695 + "Should show intent options"
705 696 );
706 697 }
707 698
@@ -887,11 +878,11 @@ async fn join_wizard_redirect_if_logged_in() {
887 878 }
888 879
889 880 #[tokio::test]
890 - async fn join_wizard_pitch_auto_skip() {
881 + async fn join_wizard_removed_steps_return_404() {
891 882 let mut h = TestHarness::new().await;
892 883 h.client.fetch_csrf_token().await;
893 884
894 - // Create account (email won't be verified)
885 + // Create account
895 886 let resp = h
896 887 .client
897 888 .post_form(
@@ -901,12 +892,10 @@ async fn join_wizard_pitch_auto_skip() {
901 892 .await;
902 893 assert!(resp.status.is_success());
903 894
904 - // GET pitch step — should auto-skip to stripe (email not verified)
895 + // Pitch and stripe steps no longer exist (removed in onboarding overhaul)
905 896 let resp = h.client.get("/join/step/pitch").await;
906 - assert!(resp.status.is_success());
907 - assert!(
908 - resp.text.contains("Payment Setup"),
909 - "Pitch should auto-skip to stripe for unverified email, got: {}",
910 - &resp.text[..200.min(resp.text.len())]
911 - );
897 + assert_eq!(resp.status, 404, "Pitch step should be removed");
898 +
899 + let resp = h.client.get("/join/step/stripe").await;
900 + assert_eq!(resp.status, 404, "Stripe step should be removed");
912 901 }