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
12 files changed, +194 insertions, -467 deletions
@@ -32,7 +32,7 @@ Usability audit grade: B. Complexity C+, Completeness B+, Learnability B, Discov
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)
@@ -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 @@ pub(super) fn format_price_display(price_cents: i32, pwyw_enabled: bool, pwyw_mi
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 @@ pub async fn wizard_page(
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 @@ pub async fn step_type_create(
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 @@ pub async fn step_save(
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 @@ pub async fn step_save(
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 @@ use crate::{
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 @@ pub(super) async fn render_step(
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 @@ pub(super) async fn render_step(
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 @@ pub(super) async fn render_step(
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 @@ pub(super) async fn render_step(
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 @@ pub(super) async fn render_step(
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 @@ pub(super) async fn render_step(
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 }
@@ -53,7 +53,7 @@ pub(super) async fn save_type(
53 53 Ok(())
54 54 }
55 55
56 - pub(super) async fn save_details(
56 + pub(super) async fn save_basics(
57 57 state: &AppState,
58 58 item: &db::DbItem,
59 59 form: &HashMap<String, String>,
@@ -85,21 +85,15 @@ pub(super) async fn save_details(
85 85 None, None, // ai_tier, ai_disclosure
86 86 )
87 87 .await?;
88 - Ok(())
89 - }
90 88
91 - pub(super) async fn save_appearance(
92 - state: &AppState,
93 - item: &db::DbItem,
94 - form: &HashMap<String, String>,
95 - ) -> Result<()> {
96 - // The cover_image_url is set via the JS upload flow (presign -> S3 PUT -> confirm).
89 + // Cover image URL is set via the JS upload flow (presign -> S3 PUT -> confirm).
97 90 // The hidden field carries the URL so we can store it if the user uploaded one.
98 91 if let Some(url) = form.get("cover_image_url")
99 92 && !url.is_empty()
100 93 {
101 94 db::items::update_item_cover_image_url(&state.db, item.id, url).await?;
102 95 }
96 +
103 97 Ok(())
104 98 }
105 99
@@ -242,37 +236,6 @@ pub(super) async fn save_pricing(
242 236 Ok(())
243 237 }
244 238
245 - pub(super) async fn save_distribution(
246 - state: &AppState,
247 - item: &db::DbItem,
248 - form: &HashMap<String, String>,
249 - user_id: UserId,
250 - ) -> Result<()> {
251 - // License key settings
252 - let enable_keys = form.get("enable_license_keys").is_some();
253 - let max_activations: Option<i32> = form
254 - .get("max_activations")
255 - .and_then(|v| v.parse().ok());
256 -
257 - db::items::update_item_license_settings(&state.db, item.id, user_id, enable_keys, max_activations)
258 - .await?;
259 -
260 - // License text preset
261 - let preset = form.get("license_preset").map(|s| s.as_str()).filter(|s| !s.is_empty());
262 - if let Some(preset_key) = preset {
263 - // Validate preset key
264 - let _: crate::license_templates::LicensePreset = preset_key
265 - .parse()
266 - .map_err(|_| AppError::Validation("Invalid license preset".into()))?;
267 - let custom_text = form.get("custom_license_text").map(|s| s.as_str()).filter(|s| !s.is_empty());
268 - db::items::update_item_license_text(&state.db, item.id, user_id, Some(preset_key), custom_text).await?;
269 - } else {
270 - db::items::update_item_license_text(&state.db, item.id, user_id, None, None).await?;
271 - }
272 -
273 - Ok(())
274 - }
275 -
276 239 pub(super) async fn save_preview(
277 240 state: &AppState,
278 241 user: &crate::auth::SessionUser,
@@ -390,24 +390,15 @@ pub struct WizardItemTypeTemplate {
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 @@ pub struct WizardItemPricingTemplate {
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 @@ pub struct WizardItemPreviewTemplate {
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 @@ impl_into_response!(
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 );
@@ -1,160 +0,0 @@
1 - {% include "wizards/partials/step_nav.html" %}
2 -
3 - <div class="wizard-step">
4 - <h2>Appearance</h2>
5 - <p class="step-description">Upload a logo or cover image. This appears on your item page and in search results.</p>
6 -
7 - <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/appearance"
8 - hx-target="#wizard-step" hx-swap="innerHTML"
9 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content">
10 -
11 - <div class="form-group">
12 - <label>Item Image</label>
13 -
14 - <div class="project-image-upload" id="image-upload-area">
15 - <div class="project-image-dropzone" id="image-dropzone">
16 - {% if let Some(url) = cover_image_url %}
17 - <div class="project-image-current" id="image-current">
18 - <img src="{{ url }}" alt="Item image">
19 - </div>
20 - {% else %}
21 - <div class="project-image-placeholder" id="image-placeholder">
22 - <p>Square, at least 400x400px</p>
23 - <p class="hint">JPG, PNG, or WebP. Max 10 MB.</p>
24 - </div>
25 - {% endif %}
26 - <input type="file" id="image-file-input" accept="image/jpeg,image/png,image/webp" style="display: none;">
27 - </div>
28 -
29 - <button type="button" class="secondary" id="choose-image-btn"
30 - onclick="document.getElementById('image-file-input').click()">Choose File</button>
31 -
32 - <div class="upload-status" id="image-upload-status">
33 - <div class="upload-progress-inline hidden" id="image-upload-progress">
34 - <span id="image-upload-filename"></span>
35 - <span id="image-upload-percent">0%</span>
36 - <div class="progress-bar-container">
37 - <div class="progress-bar" id="image-progress-bar" style="width: 0%;"></div>
38 - </div>
39 - </div>
40 - <div class="upload-error-inline hidden" id="image-upload-error">
41 - <span class="error-message" id="image-error-message"></span>
42 - </div>
43 - </div>
44 - </div>
45 -
46 - <input type="hidden" name="cover_image_url" id="cover-image-url"
47 - value="{% if let Some(url) = cover_image_url %}{{ url }}{% endif %}">
48 - </div>
49 -
50 - <div class="wizard-actions">
51 - <button type="button" class="secondary"
52 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/details"
53 - hx-target="#wizard-step" hx-swap="innerHTML"
54 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/details">Back</button>
55 - <button type="button" class="secondary"
56 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content"
57 - hx-target="#wizard-step" hx-swap="innerHTML"
58 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content">Skip</button>
59 - <button type="submit" class="primary">Continue</button>
60 - </div>
61 - </form>
62 - </div>
63 -
64 - <script>
65 - (function() {
66 - var itemId = '{{ item_id }}';
67 - var dropzone = document.getElementById('image-dropzone');
68 - var fileInput = document.getElementById('image-file-input');
69 - var hiddenUrl = document.getElementById('cover-image-url');
70 - var progressEl = document.getElementById('image-upload-progress');
71 - var errorEl = document.getElementById('image-upload-error');
72 - var placeholder = document.getElementById('image-placeholder');
73 - var currentImg = document.getElementById('image-current');
74 - var chooseBtn = document.getElementById('choose-image-btn');
75 -
76 - var uploader = new S3Uploader({
77 - filenameEl: document.getElementById('image-upload-filename'),
78 - percentEl: document.getElementById('image-upload-percent'),
79 - progressBar: document.getElementById('image-progress-bar'),
80 - });
81 -
82 - initDropzone(dropzone, fileInput, function(file) {
83 - uploadItemImage(file);
84 - });
85 -
86 - function uploadItemImage(file) {
87 - errorEl.classList.add('hidden');
88 - progressEl.classList.remove('hidden');
89 - chooseBtn.disabled = true;
90 -
91 - fetch('/api/items/image/presign', {
92 - method: 'POST',
93 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
94 - body: JSON.stringify({
95 - item_id: itemId,
96 - file_name: file.name,
97 - content_type: file.type || 'image/jpeg'
98 - })
99 - })
100 - .then(function(res) {
101 - if (!res.ok) return res.text().then(function(body) {
102 - try { var d = JSON.parse(body); throw new Error(d.error || 'Failed to get upload URL'); }
103 - catch(e) { if (e.message && e.message !== body) throw e; throw new Error('Failed to get upload URL'); }
104 - });
105 - return res.json();
106 - })
107 - .then(function(data) {
108 - return uploader.upload(data.upload_url, file, data.s3_key, file.type || 'image/jpeg', data.cache_control);
109 - })
110 - .then(function(s3Key) {
111 - return fetch('/api/items/image/confirm', {
112 - method: 'POST',
113 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
114 - body: JSON.stringify({
115 - item_id: itemId,
116 - s3_key: s3Key
117 - })
118 - });
119 - })
120 - .then(function(res) {
121 - if (!res.ok) return res.text().then(function(body) {
122 - try { var d = JSON.parse(body); throw new Error(d.error || 'Failed to confirm upload'); }
123 - catch(e) { if (e.message && e.message !== body) throw e; throw new Error('Failed to confirm upload'); }
124 - });
125 - return res.json();
126 - })
127 - .then(function(data) {
128 - hiddenUrl.value = data.image_url;
129 - progressEl.classList.add('hidden');
130 - chooseBtn.disabled = false;
131 - showUploadedImage(data.image_url);
132 - })
133 - .catch(function(err) {
134 - progressEl.classList.add('hidden');
135 - chooseBtn.disabled = false;
136 - showError(err.message || 'Upload failed. Please try again.');
137 - });
138 - }
139 -
140 - function showUploadedImage(url) {
141 - if (placeholder) placeholder.classList.add('hidden');
142 - if (currentImg) {
143 - currentImg.querySelector('img').src = url;
144 - currentImg.classList.remove('hidden');
145 - } else {
146 - var div = document.createElement('div');
147 - div.className = 'project-image-current';
148 - div.id = 'image-current';
149 - div.innerHTML = '<img src="' + url + '" alt="Item image">';
150 - dropzone.insertBefore(div, fileInput);
151 - currentImg = div;
152 - }
153 - }
154 -
155 - function showError(message) {
156 - errorEl.classList.remove('hidden');
157 - document.getElementById('image-error-message').textContent = message;
158 - }
159 - })();
160 - </script>
@@ -0,0 +1,168 @@
1 + {% include "wizards/partials/step_nav.html" %}
2 +
3 + <div class="wizard-step">
4 + <h2>Item Basics</h2>
5 + <p class="step-description">Title, description, and cover image for your item.</p>
6 +
7 + <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/basics"
8 + hx-target="#wizard-step" hx-swap="innerHTML"
9 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content">
10 + <div class="form-group">
11 + <label for="wiz-item-title">Title</label>
12 + <input type="text" id="wiz-item-title" name="title" required
13 + value="{{ title }}"
14 + placeholder="Episode 1, Chapter 1, Track 1..." autocomplete="off">
15 + </div>
16 +
17 + <div class="form-group">
18 + <label for="wiz-item-desc">Description</label>
19 + <textarea id="wiz-item-desc" name="description" rows="5"
20 + placeholder="Describe this item...">{{ description }}</textarea>
21 + </div>
22 +
23 + <div class="form-group">
24 + <label>Cover Image <span style="opacity: 0.5; font-weight: normal;">(optional)</span></label>
25 +
26 + <div class="project-image-upload" id="image-upload-area">
27 + <div class="project-image-dropzone" id="image-dropzone">
28 + {% if let Some(url) = cover_image_url %}
29 + <div class="project-image-current" id="image-current">
30 + <img src="{{ url }}" alt="Item image">
31 + </div>
32 + {% else %}
33 + <div class="project-image-placeholder" id="image-placeholder">
34 + <p>Square, at least 400x400px</p>
35 + <p class="hint">JPG, PNG, or WebP. Max 10 MB.</p>
36 + </div>
37 + {% endif %}
38 + <input type="file" id="image-file-input" accept="image/jpeg,image/png,image/webp" style="display: none;">
39 + </div>
40 +
41 + <button type="button" class="secondary" id="choose-image-btn"
42 + onclick="document.getElementById('image-file-input').click()">Choose File</button>
43 +
44 + <div class="upload-status" id="image-upload-status">
45 + <div class="upload-progress-inline hidden" id="image-upload-progress">
46 + <span id="image-upload-filename"></span>
47 + <span id="image-upload-percent">0%</span>
48 + <div class="progress-bar-container">
49 + <div class="progress-bar" id="image-progress-bar" style="width: 0%;"></div>
50 + </div>
51 + </div>
52 + <div class="upload-error-inline hidden" id="image-upload-error">
53 + <span class="error-message" id="image-error-message"></span>
54 + </div>
55 + </div>
56 + </div>
57 +
58 + <input type="hidden" name="cover_image_url" id="cover-image-url"
59 + value="{% if let Some(url) = cover_image_url %}{{ url }}{% endif %}">
60 + </div>
61 +
62 + <div class="wizard-actions">
63 + <button type="button" class="secondary"
64 + hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/type"
65 + hx-target="#wizard-step" hx-swap="innerHTML"
66 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/type">Back</button>
67 + <button type="submit" class="primary">Continue</button>
68 + </div>
69 + </form>
70 + </div>
71 +
72 + <script>
73 + (function() {
74 + var itemId = '{{ item_id }}';
75 + var dropzone = document.getElementById('image-dropzone');
76 + var fileInput = document.getElementById('image-file-input');
77 + var hiddenUrl = document.getElementById('cover-image-url');
78 + var progressEl = document.getElementById('image-upload-progress');
79 + var errorEl = document.getElementById('image-upload-error');
80 + var placeholder = document.getElementById('image-placeholder');
81 + var currentImg = document.getElementById('image-current');
82 + var chooseBtn = document.getElementById('choose-image-btn');
83 +
84 + var uploader = new S3Uploader({
85 + filenameEl: document.getElementById('image-upload-filename'),
86 + percentEl: document.getElementById('image-upload-percent'),
87 + progressBar: document.getElementById('image-progress-bar'),
88 + });
89 +
90 + initDropzone(dropzone, fileInput, function(file) {
91 + uploadItemImage(file);
92 + });
93 +
94 + function uploadItemImage(file) {
95 + errorEl.classList.add('hidden');
96 + progressEl.classList.remove('hidden');
97 + chooseBtn.disabled = true;
98 +
99 + fetch('/api/items/image/presign', {
100 + method: 'POST',
101 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
102 + body: JSON.stringify({
103 + item_id: itemId,
104 + file_name: file.name,
105 + content_type: file.type || 'image/jpeg'
106 + })
107 + })
108 + .then(function(res) {
109 + if (!res.ok) return res.text().then(function(body) {
110 + try { var d = JSON.parse(body); throw new Error(d.error || 'Failed to get upload URL'); }
111 + catch(e) { if (e.message && e.message !== body) throw e; throw new Error('Failed to get upload URL'); }
112 + });
113 + return res.json();
114 + })
115 + .then(function(data) {
116 + return uploader.upload(data.upload_url, file, data.s3_key, file.type || 'image/jpeg', data.cache_control);
117 + })
118 + .then(function(s3Key) {
119 + return fetch('/api/items/image/confirm', {
120 + method: 'POST',
121 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
122 + body: JSON.stringify({
123 + item_id: itemId,
124 + s3_key: s3Key
125 + })
126 + });
127 + })
128 + .then(function(res) {
129 + if (!res.ok) return res.text().then(function(body) {
130 + try { var d = JSON.parse(body); throw new Error(d.error || 'Failed to confirm upload'); }
131 + catch(e) { if (e.message && e.message !== body) throw e; throw new Error('Failed to confirm upload'); }
132 + });
133 + return res.json();
134 + })
135 + .then(function(data) {
136 + hiddenUrl.value = data.image_url;
137 + progressEl.classList.add('hidden');
138 + chooseBtn.disabled = false;
139 + showUploadedImage(data.image_url);
140 + })
141 + .catch(function(err) {
142 + progressEl.classList.add('hidden');
143 + chooseBtn.disabled = false;
144 + showError(err.message || 'Upload failed. Please try again.');
145 + });
146 + }
147 +
148 + function showUploadedImage(url) {
149 + if (placeholder) placeholder.classList.add('hidden');
150 + if (currentImg) {
151 + currentImg.querySelector('img').src = url;
152 + currentImg.classList.remove('hidden');
153 + } else {
154 + var div = document.createElement('div');
155 + div.className = 'project-image-current';
156 + div.id = 'image-current';
157 + div.innerHTML = '<img src="' + url + '" alt="Item image">';
158 + dropzone.insertBefore(div, fileInput);
159 + currentImg = div;
160 + }
161 + }
162 +
163 + function showError(message) {
164 + errorEl.classList.remove('hidden');
165 + document.getElementById('image-error-message').textContent = message;
166 + }
167 + })();
168 + </script>
@@ -1,31 +0,0 @@
1 - {% include "wizards/partials/step_nav.html" %}
2 -
3 - <div class="wizard-step">
4 - <h2>Details</h2>
5 - <p class="step-description">Give your item a title and description.</p>
6 -
7 - <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/details"
8 - hx-target="#wizard-step" hx-swap="innerHTML"
9 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/appearance">
10 - <div class="form-group">
11 - <label for="wiz-item-title">Title</label>
12 - <input type="text" id="wiz-item-title" name="title" required
13 - value="{{ title }}"
14 - placeholder="Episode 1, Chapter 1, Track 1..." autocomplete="off">
15 - </div>
16 -
17 - <div class="form-group">
18 - <label for="wiz-item-desc">Description</label>
19 - <textarea id="wiz-item-desc" name="description" rows="5"
20 - placeholder="Describe this item...">{{ description }}</textarea>
21 - </div>
22 -
23 - <div class="wizard-actions">
24 - <button type="button" class="secondary"
25 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/type"
26 - hx-target="#wizard-step" hx-swap="innerHTML"
27 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/type">Back</button>
28 - <button type="submit" class="primary">Continue</button>
29 - </div>
30 - </form>
31 - </div>
@@ -1,84 +0,0 @@
1 - {% include "wizards/partials/step_nav.html" %}
2 -
3 - <div class="wizard-step">
4 - <h2>Distribution</h2>
5 - <p class="step-description">Configure license terms, license keys, and promotional codes.</p>
6 -
7 - <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution"
8 - hx-target="#wizard-step" hx-swap="innerHTML"
9 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview"
10 - novalidate>
11 -
12 - <div class="distribution-section">
13 - <h3>License Terms</h3>
14 - <p class="hint">Choose a license to display on the item page and include with downloads.</p>
15 -
16 - <div class="form-group">
17 - <label for="wiz-license-preset">License</label>
18 - <select id="wiz-license-preset" name="license_preset"
19 - onchange="document.getElementById('wiz-custom-license').style.display = this.value === 'custom' ? '' : 'none'">
20 - <option value="">None (no license)</option>
21 - {% for opt in license_preset_options %}
22 - <option value="{{ opt.0 }}"{% if license_preset.as_deref() == Some(opt.0) %} selected{% endif %}>{{ opt.1 }}</option>
23 - {% endfor %}
24 - </select>
25 - </div>
26 -
27 - <div id="wiz-custom-license" style="{% if license_preset.as_deref() != Some("custom") %}display:none{% endif %}">
28 - <div class="form-group">
29 - <label for="wiz-custom-text">Custom License Text</label>
30 - <textarea id="wiz-custom-text" name="custom_license_text" rows="6"
31 - placeholder="Enter your custom license terms...">{{ custom_license_text }}</textarea>
32 - </div>
33 - </div>
34 - </div>
35 -
36 - {% if show_license_keys %}
37 - <div class="distribution-section" style="margin-top: 1.5rem;">
38 - <h3>License Keys</h3>
39 - <p class="hint">Automatically generate unique license keys for each purchase.</p>
40 -
41 - <div class="form-group">
42 - <label class="checkbox-label">
43 - <input type="checkbox" name="enable_license_keys" value="true"
44 - {% if enable_license_keys %}checked{% endif %}
45 - onchange="document.getElementById('license-fields').style.display = this.checked ? '' : 'none'">
46 - Enable license keys
47 - </label>
48 - </div>
49 -
50 - <div id="license-fields" style="{% if !enable_license_keys %}display:none{% endif %}">
51 - <div class="form-group">
52 - <label for="wiz-max-act">Max Activations per Key</label>
53 - <input type="number" id="wiz-max-act" name="max_activations"
54 - min="1" step="1"
55 - value="{% if let Some(max) = max_activations %}{{ max }}{% else %}3{% endif %}"
56 - placeholder="3">
57 - <div class="hint">How many devices can use a single key.</div>
58 - </div>
59 - </div>
60 - </div>
61 - {% else %}
62 - <div class="info-box">
63 - <p>License keys are available for software, plugins, presets, templates, courses, and digital downloads.</p>
64 - </div>
65 - {% endif %}
66 -
67 - <div class="distribution-section" style="margin-top: 1.5rem;">
68 - <h3>Promo Codes</h3>
69 - <p class="hint">Create promo codes from the item dashboard after publishing.</p>
70 - </div>
71 -
72 - <div class="wizard-actions">
73 - <button type="button" class="secondary"
74 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing"
75 - hx-target="#wizard-step" hx-swap="innerHTML"
76 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing">Back</button>
77 - <button type="button" class="secondary"
78 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview"
79 - hx-target="#wizard-step" hx-swap="innerHTML"
80 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview">Skip</button>
81 - <button type="submit" class="primary">Continue</button>
82 - </div>
83 - </form>
84 - </div>
@@ -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">