max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 files changed,
+44 insertions,
-317 deletions
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | ### Critical (broken or high-dropout flows) | |
| 33 | 33 | ||
| 34 | 34 | - [ ] **[HIGH]** Add "Add to collection" UI — fans can create collections but cannot add items to them. Add dropdown/button on library purchase rows and on item pages (`library_purchases.html`, `item.html`) | |
| 35 | - | - [ ] **[HIGH]** Consolidate item creation wizard from 8 steps to 5 — merge Details+Appearance into one step, move Distribution (license keys, promo codes) to post-creation item dashboard tabs. Current 8-step gauntlet has ~40% estimated dropout for first-time creators | |
| 35 | + | - [x] **[HIGH]** Consolidate item creation wizard from 8 steps to 6 — merged Details+Appearance into "Basics" step, removed Distribution step (already in dashboard Pricing tab) | |
| 36 | 36 | - [ ] **[HIGH]** Add global search to site header — search only exists on /discover page. Add input to `site_header.html` with Cmd+K shortcut | |
| 37 | 37 | ||
| 38 | 38 | ### High (significant friction reduction) |
| @@ -390,24 +390,15 @@ | |||
| 390 | 390 | pub selected_type: String, | |
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | - | /// Wizard step partial: item details (title, description). | |
| 393 | + | /// Wizard step partial: item basics (title, description, cover image). | |
| 394 | 394 | #[derive(Template)] | |
| 395 | - | #[template(path = "wizards/steps/item/details.html")] | |
| 396 | - | pub struct WizardItemDetailsTemplate { | |
| 395 | + | #[template(path = "wizards/steps/item/basics.html")] | |
| 396 | + | pub struct WizardItemBasicsTemplate { | |
| 397 | 397 | pub nav: Vec<StepNavItem>, | |
| 398 | 398 | pub project_slug: String, | |
| 399 | 399 | pub item_id: String, | |
| 400 | 400 | pub title: String, | |
| 401 | 401 | pub description: String, | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | /// Wizard step partial: item appearance (cover image upload). | |
| 405 | - | #[derive(Template)] | |
| 406 | - | #[template(path = "wizards/steps/item/appearance.html")] | |
| 407 | - | pub struct WizardItemAppearanceTemplate { | |
| 408 | - | pub nav: Vec<StepNavItem>, | |
| 409 | - | pub project_slug: String, | |
| 410 | - | pub item_id: String, | |
| 411 | 402 | pub cover_image_url: Option<String>, | |
| 412 | 403 | } | |
| 413 | 404 | ||
| @@ -457,29 +448,11 @@ | |||
| 457 | 448 | pub price_dollars: String, | |
| 458 | 449 | pub pwyw_suggested_dollars: String, | |
| 459 | 450 | pub pwyw_min_dollars: String, | |
| 460 | - | pub next_step: String, | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | /// Wizard step partial: item distribution (license keys, license preset). | |
| 464 | - | #[derive(Template)] | |
| 465 | - | #[template(path = "wizards/steps/item/distribution.html")] | |
| 466 | - | #[allow(dead_code)] | |
| 467 | - | pub struct WizardItemDistributionTemplate { | |
| 468 | - | pub nav: Vec<StepNavItem>, | |
| 469 | - | pub project_slug: String, | |
| 470 | - | pub item_id: String, | |
| 471 | - | pub show_license_keys: bool, | |
| 472 | - | pub enable_license_keys: bool, | |
| 473 | - | pub max_activations: Option<i32>, | |
| 474 | - | pub license_preset_options: Vec<(&'static str, &'static str)>, | |
| 475 | - | pub license_preset: Option<String>, | |
| 476 | - | pub custom_license_text: String, | |
| 477 | 451 | } | |
| 478 | 452 | ||
| 479 | 453 | /// Wizard step partial: item preview and publish confirmation. | |
| 480 | 454 | #[derive(Template)] | |
| 481 | 455 | #[template(path = "wizards/steps/item/preview.html")] | |
| 482 | - | #[allow(dead_code)] | |
| 483 | 456 | pub struct WizardItemPreviewTemplate { | |
| 484 | 457 | pub csrf_token: CsrfTokenOption, | |
| 485 | 458 | pub nav: Vec<StepNavItem>, | |
| @@ -490,8 +463,6 @@ | |||
| 490 | 463 | pub description: String, | |
| 491 | 464 | pub price_display: String, | |
| 492 | 465 | pub tag_names: Vec<String>, | |
| 493 | - | pub enable_license_keys: bool, | |
| 494 | 466 | pub has_content: bool, | |
| 495 | 467 | pub is_public: bool, | |
| 496 | - | pub back_step: String, | |
| 497 | 468 | } |
| @@ -217,11 +217,9 @@ | |||
| 217 | 217 | WizardProjectPreviewTemplate, | |
| 218 | 218 | // Creation wizards — item step partials | |
| 219 | 219 | WizardItemTypeTemplate, | |
| 220 | - | WizardItemDetailsTemplate, | |
| 221 | - | WizardItemAppearanceTemplate, | |
| 220 | + | WizardItemBasicsTemplate, | |
| 222 | 221 | WizardItemContentTemplate, | |
| 223 | 222 | WizardItemSectionsTemplate, | |
| 224 | 223 | WizardItemPricingTemplate, | |
| 225 | - | WizardItemDistributionTemplate, | |
| 226 | 224 | WizardItemPreviewTemplate, | |
| 227 | 225 | ); |
| @@ -33,12 +33,6 @@ | |||
| 33 | 33 | <span class="preview-value">{{ tag_names.join(", ") }}</span> | |
| 34 | 34 | </div> | |
| 35 | 35 | {% endif %} | |
| 36 | - | {% if enable_license_keys %} | |
| 37 | - | <div class="preview-row"> | |
| 38 | - | <span class="preview-label">License Keys</span> | |
| 39 | - | <span class="preview-value">Enabled</span> | |
| 40 | - | </div> | |
| 41 | - | {% endif %} | |
| 42 | 36 | </div> | |
| 43 | 37 | ||
| 44 | 38 | <div class="preview-checklist"> | |
| @@ -62,9 +56,9 @@ | |||
| 62 | 56 | ||
| 63 | 57 | <div class="wizard-actions"> | |
| 64 | 58 | <button type="button" class="secondary" | |
| 65 | - | hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}" | |
| 59 | + | hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing" | |
| 66 | 60 | hx-target="#wizard-step" hx-swap="innerHTML" | |
| 67 | - | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}">Back</button> | |
| 61 | + | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing">Back</button> | |
| 68 | 62 | <button type="submit" name="action" value="draft" class="secondary">Save as Draft</button> | |
| 69 | 63 | <button type="button" class="secondary" | |
| 70 | 64 | onclick="var f=document.getElementById('schedule-fields'); f.style.display=f.style.display==='none'?'':'none';">Schedule</button> |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing" | |
| 8 | 8 | hx-target="#wizard-step" hx-swap="innerHTML" | |
| 9 | - | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ next_step }}" | |
| 9 | + | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview" | |
| 10 | 10 | novalidate> | |
| 11 | 11 | ||
| 12 | 12 | <div class="pricing-cards"> |
| @@ -1,5 +1,5 @@ | |||
| 1 | - | //! Item creation wizard — 6 steps: type, details, content, pricing, | |
| 2 | - | //! distribution, preview. | |
| 1 | + | //! Item creation wizard — 6 steps: type, basics, content, sections, | |
| 2 | + | //! pricing, preview. | |
| 3 | 3 | ||
| 4 | 4 | mod render; | |
| 5 | 5 | mod save; | |
| @@ -39,50 +39,23 @@ | |||
| 39 | 39 | /// Ordered step names for the item wizard. | |
| 40 | 40 | pub const ITEM_STEPS: &[&str] = &[ | |
| 41 | 41 | "type", | |
| 42 | - | "details", | |
| 43 | - | "appearance", | |
| 42 | + | "basics", | |
| 44 | 43 | "content", | |
| 45 | 44 | "sections", | |
| 46 | 45 | "pricing", | |
| 47 | - | "distribution", | |
| 48 | 46 | "preview", | |
| 49 | 47 | ]; | |
| 50 | 48 | ||
| 51 | 49 | /// Human-readable labels for each step. | |
| 52 | 50 | pub(super) const ITEM_LABELS: &[&str] = &[ | |
| 53 | 51 | "Type", | |
| 54 | - | "Details", | |
| 55 | - | "Appearance", | |
| 52 | + | "Basics", | |
| 56 | 53 | "Content", | |
| 57 | 54 | "Sections", | |
| 58 | 55 | "Pricing", | |
| 59 | - | "Distribution", | |
| 60 | 56 | "Preview", | |
| 61 | 57 | ]; | |
| 62 | 58 | ||
| 63 | - | /// Whether the distribution step has actionable content for this item type. | |
| 64 | - | /// Only types that support license keys need the distribution step. | |
| 65 | - | pub(super) fn needs_distribution(item_type: ItemType) -> bool { | |
| 66 | - | matches!( | |
| 67 | - | item_type, | |
| 68 | - | ItemType::Plugin | ItemType::Preset | ItemType::Template | ItemType::Digital | ItemType::Course | |
| 69 | - | ) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | /// Get effective steps and labels for an item type, excluding distribution when not needed. | |
| 73 | - | pub(super) fn effective_steps(item_type: ItemType) -> (Vec<&'static str>, Vec<&'static str>) { | |
| 74 | - | if needs_distribution(item_type) { | |
| 75 | - | (ITEM_STEPS.to_vec(), ITEM_LABELS.to_vec()) | |
| 76 | - | } else { | |
| 77 | - | let steps: Vec<&str> = ITEM_STEPS.iter().copied().filter(|&s| s != "distribution").collect(); | |
| 78 | - | let labels: Vec<&str> = ITEM_STEPS.iter().zip(ITEM_LABELS.iter()) | |
| 79 | - | .filter(|(s, _)| **s != "distribution") | |
| 80 | - | .map(|(_, &l)| l) | |
| 81 | - | .collect(); | |
| 82 | - | (steps, labels) | |
| 83 | - | } | |
| 84 | - | } | |
| 85 | - | ||
| 86 | 59 | /// Verify the user owns the project + item for wizard steps 2-6. | |
| 87 | 60 | async fn verify_item_wizard_access( | |
| 88 | 61 | state: &AppState, | |
| @@ -150,7 +123,7 @@ | |||
| 150 | 123 | .await?; | |
| 151 | 124 | ||
| 152 | 125 | return Ok(axum::response::Redirect::to(&format!( | |
| 153 | - | "/dashboard/project/{}/new-item/{}/step/details", | |
| 126 | + | "/dashboard/project/{}/new-item/{}/step/basics", | |
| 154 | 127 | slug, item.id | |
| 155 | 128 | )) | |
| 156 | 129 | .into_response()); | |
| @@ -220,8 +193,8 @@ | |||
| 220 | 193 | ) | |
| 221 | 194 | .await?; | |
| 222 | 195 | ||
| 223 | - | // Return step 2 (details) partial | |
| 224 | - | render::render_step(&state, &session, &user, &project, &item, "details").await | |
| 196 | + | // Return step 2 (basics) partial | |
| 197 | + | render::render_step(&state, &session, &user, &project, &item, "basics").await | |
| 225 | 198 | } | |
| 226 | 199 | ||
| 227 | 200 | // ============================================================================= | |
| @@ -258,12 +231,10 @@ | |||
| 258 | 231 | ||
| 259 | 232 | match step.as_str() { | |
| 260 | 233 | "type" => save::save_type(&state, &project, &item, &form, user.id).await?, | |
| 261 | - | "details" => save::save_details(&state, &item, &form, user.id).await?, | |
| 262 | - | "appearance" => save::save_appearance(&state, &item, &form).await?, | |
| 234 | + | "basics" => save::save_basics(&state, &item, &form, user.id).await?, | |
| 263 | 235 | "content" => save::save_content(&state, &item, &form, user.id).await?, | |
| 264 | 236 | "sections" => {} // Sections managed via HTMX API; pass-through | |
| 265 | 237 | "pricing" => save::save_pricing(&state, &item, &form, user.id).await?, | |
| 266 | - | "distribution" => save::save_distribution(&state, &item, &form, user.id).await?, | |
| 267 | 238 | "preview" => return save::save_preview(&state, &user, &project, &item, &form).await, | |
| 268 | 239 | _ => return Err(AppError::NotFound), | |
| 269 | 240 | } | |
| @@ -273,9 +244,6 @@ | |||
| 273 | 244 | .await? | |
| 274 | 245 | .ok_or(AppError::NotFound)?; | |
| 275 | 246 | ||
| 276 | - | // Use the full step list for navigation so that saving a step that is | |
| 277 | - | // filtered out of effective_steps (e.g. distribution for text items) | |
| 278 | - | // still advances correctly. render_step handles skipping internally. | |
| 279 | 247 | let next = super::next_step(&ITEM_STEPS, &step).ok_or(AppError::NotFound)?; | |
| 280 | 248 | render::render_step(&state, &session, &user, &project, &item, next).await | |
| 281 | 249 | } |
| @@ -11,10 +11,7 @@ | |||
| 11 | 11 | AppState, | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 14 | - | use super::{ | |
| 15 | - | build_step_nav, effective_steps, format_price_display, needs_distribution, | |
| 16 | - | ITEM_LABELS, ITEM_STEPS, | |
| 17 | - | }; | |
| 14 | + | use super::{build_step_nav, format_price_display, ITEM_LABELS, ITEM_STEPS}; | |
| 18 | 15 | ||
| 19 | 16 | pub(super) async fn render_step( | |
| 20 | 17 | state: &AppState, | |
| @@ -24,8 +21,7 @@ | |||
| 24 | 21 | item: &db::DbItem, | |
| 25 | 22 | step: &str, | |
| 26 | 23 | ) -> Result<Response> { | |
| 27 | - | let (steps, labels) = effective_steps(item.item_type); | |
| 28 | - | let nav = build_step_nav(&steps, &labels, step); | |
| 24 | + | let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, step); | |
| 29 | 25 | let project_slug = project.slug.to_string(); | |
| 30 | 26 | let item_id = item.id.to_string(); | |
| 31 | 27 | let csrf_token = get_csrf_token(session).await; | |
| @@ -33,15 +29,16 @@ | |||
| 33 | 29 | match step { | |
| 34 | 30 | "type" => { | |
| 35 | 31 | let type_cards = ProjectFeature::wizard_type_cards(&project.features); | |
| 36 | - | // If only 1 behavior group, skip forward to details | |
| 32 | + | // If only 1 behavior group, skip forward to basics | |
| 37 | 33 | if type_cards.len() <= 1 { | |
| 38 | - | let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "details"); | |
| 39 | - | return Ok(WizardItemDetailsTemplate { | |
| 34 | + | let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "basics"); | |
| 35 | + | return Ok(WizardItemBasicsTemplate { | |
| 40 | 36 | nav, | |
| 41 | 37 | project_slug, | |
| 42 | 38 | item_id, | |
| 43 | 39 | title: item.title.clone(), | |
| 44 | 40 | description: item.description.clone().unwrap_or_default(), | |
| 41 | + | cover_image_url: item.cover_image_url.clone(), | |
| 45 | 42 | } | |
| 46 | 43 | .into_response()); | |
| 47 | 44 | } | |
| @@ -55,19 +52,12 @@ | |||
| 55 | 52 | .into_response()) | |
| 56 | 53 | } | |
| 57 | 54 | ||
| 58 | - | "details" => Ok(WizardItemDetailsTemplate { | |
| 55 | + | "basics" => Ok(WizardItemBasicsTemplate { | |
| 59 | 56 | nav, | |
| 60 | 57 | project_slug, | |
| 61 | 58 | item_id, | |
| 62 | 59 | title: item.title.clone(), | |
| 63 | 60 | description: item.description.clone().unwrap_or_default(), | |
| 64 | - | } | |
| 65 | - | .into_response()), | |
| 66 | - | ||
| 67 | - | "appearance" => Ok(WizardItemAppearanceTemplate { | |
| 68 | - | nav, | |
| 69 | - | project_slug, | |
| 70 | - | item_id, | |
| 71 | 61 | cover_image_url: item.cover_image_url.clone(), | |
| 72 | 62 | } | |
| 73 | 63 | .into_response()), | |
| @@ -139,8 +129,6 @@ | |||
| 139 | 129 | "free" | |
| 140 | 130 | }; | |
| 141 | 131 | ||
| 142 | - | let next = super::super::next_step(&steps, "pricing").unwrap_or("preview"); | |
| 143 | - | ||
| 144 | 132 | Ok(WizardItemPricingTemplate { | |
| 145 | 133 | nav, | |
| 146 | 134 | project_slug, | |
| @@ -160,48 +148,6 @@ | |||
| 160 | 148 | let min = item.pwyw_min_cents.unwrap_or(0); | |
| 161 | 149 | format!("{}.{:02}", min / 100, min % 100) | |
| 162 | 150 | }, | |
| 163 | - | next_step: next.to_string(), | |
| 164 | - | } | |
| 165 | - | .into_response()) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | "distribution" => { | |
| 169 | - | if !needs_distribution(item.item_type) { | |
| 170 | - | // Skip to preview for types without distribution options | |
| 171 | - | let nav = build_step_nav(&steps, &labels, "preview"); | |
| 172 | - | let tags = db::tags::get_tags_for_item(&state.db, item.id).await?; | |
| 173 | - | let tag_names: Vec<String> = tags.iter().map(|t| t.tag_name.clone()).collect(); | |
| 174 | - | return Ok(WizardItemPreviewTemplate { | |
| 175 | - | csrf_token, | |
| 176 | - | nav, | |
| 177 | - | project_slug, | |
| 178 | - | item_id, | |
| 179 | - | title: item.title.clone(), | |
| 180 | - | item_type: item.item_type.to_string(), | |
| 181 | - | description: item.description.clone().unwrap_or_default(), | |
| 182 | - | price_display: format_price_display(item.price_cents, item.pwyw_enabled, item.pwyw_min_cents), | |
| 183 | - | tag_names, | |
| 184 | - | enable_license_keys: item.enable_license_keys, | |
| 185 | - | has_content: item.body.is_some() | |
| 186 | - | || item.audio_s3_key.is_some() | |
| 187 | - | || item.video_s3_key.is_some() | |
| 188 | - | || (item.item_type == ItemType::Bundle | |
| 189 | - | && db::bundles::get_bundle_item_count(&state.db, item.id).await? > 0), | |
| 190 | - | is_public: item.is_public, | |
| 191 | - | back_step: "pricing".to_string(), | |
| 192 | - | }.into_response()); | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | Ok(WizardItemDistributionTemplate { | |
| 196 | - | nav, | |
| 197 | - | project_slug: project_slug.clone(), | |
| 198 | - | item_id: item_id.clone(), | |
| 199 | - | show_license_keys: true, | |
| 200 | - | enable_license_keys: item.enable_license_keys, | |
| 201 | - | max_activations: item.default_max_activations, | |
| 202 | - | license_preset_options: crate::license_templates::preset_options(), | |
| 203 | - | license_preset: item.license_preset.clone(), | |
| 204 | - | custom_license_text: item.custom_license_text.clone().unwrap_or_default(), | |
| 205 | 151 | } | |
| 206 | 152 | .into_response()) | |
| 207 | 153 | } | |
| @@ -220,18 +166,12 @@ | |||
| 220 | 166 | description: item.description.clone().unwrap_or_default(), | |
| 221 | 167 | price_display: format_price_display(item.price_cents, item.pwyw_enabled, item.pwyw_min_cents), | |
| 222 | 168 | tag_names, | |
| 223 | - | enable_license_keys: item.enable_license_keys, | |
| 224 | 169 | has_content: item.body.is_some() | |
| 225 | 170 | || item.audio_s3_key.is_some() | |
| 226 | 171 | || item.video_s3_key.is_some() | |
| 227 | 172 | || (item.item_type == ItemType::Bundle | |
| 228 | 173 | && db::bundles::get_bundle_item_count(&state.db, item.id).await? > 0), | |
| 229 | 174 | is_public: item.is_public, | |
| 230 | - | back_step: if needs_distribution(item.item_type) { | |
| 231 | - | "distribution".to_string() | |
| 232 | - | } else { | |
| 233 | - | "pricing".to_string() | |
| 234 | - | }, | |
| 235 | 175 | } | |
| 236 | 176 | .into_response()) | |
| 237 | 177 | } |