Skip to main content

max / makenotwork

Consolidate item wizard from 8 steps to 6 Merge Details + Appearance into single "Basics" step (title, description, cover image in one form). Remove Distribution step entirely — license keys and promo codes are already manageable from the dashboard Pricing tab. Steps: Type > Basics > Content > Sections > Pricing > Preview
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 02:30 UTC
Commit: fc10ea0bf0b85988065dde50dd41eed41e221af2
Parent: 056335f
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 }