max / makenotwork
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 | } |