Skip to main content

max / makenotwork

Bundle dashboard management, wizard fixes, upload progress bars - Bundle item dashboard: add/remove items, toggle unlisted status via three new API endpoints (bundle/add, bundle/{id}, bundle/{id}/listed) - Skip distribution wizard step for item types without license keys - Fix pricing step: add novalidate to prevent hidden field min validation from blocking form submission on free/PWYW - Batch upload: replace "Uploading..." text with inline progress bars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 01:23 UTC
Commit: cc04eb78d5a8dbed0803acfb1e2539eb539e4103
Parent: 2127f74
10 files changed, +314 insertions, -36 deletions
@@ -802,3 +802,77 @@ pub(super) async fn list_versions(
802 802
803 803 Ok(Json(ListResponse { data }))
804 804 }
805 +
806 + // =============================================================================
807 + // Bundle management
808 + // =============================================================================
809 +
810 + #[derive(Debug, Deserialize)]
811 + pub struct BundleAddRequest {
812 + pub item_id: ItemId,
813 + }
814 +
815 + #[derive(Debug, Deserialize)]
816 + pub struct BundleListedRequest {
817 + pub listed: bool,
818 + }
819 +
820 + /// POST /api/items/{id}/bundle/add — add an item to this bundle.
821 + #[tracing::instrument(skip_all, name = "items::bundle_add")]
822 + pub async fn bundle_add(
823 + State(state): State<AppState>,
824 + AuthUser(user): AuthUser,
825 + Path(bundle_id): Path<ItemId>,
826 + Json(req): Json<BundleAddRequest>,
827 + ) -> Result<impl IntoResponse> {
828 + user.check_not_suspended()?;
829 + let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?;
830 + if item.item_type != ItemType::Bundle {
831 + return Err(AppError::BadRequest("Item is not a bundle".to_string()));
832 + }
833 + let target = db::items::get_item_by_id(&state.db, req.item_id)
834 + .await?
835 + .ok_or(AppError::NotFound)?;
836 + if target.project_id != item.project_id {
837 + return Err(AppError::BadRequest("Item must be in the same project".to_string()));
838 + }
839 + if target.item_type == ItemType::Bundle {
840 + return Err(AppError::BadRequest("Cannot nest bundles".to_string()));
841 + }
842 + let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?;
843 + db::bundles::add_item_to_bundle(&state.db, bundle_id, req.item_id, count as i32).await?;
844 + Ok(StatusCode::OK)
845 + }
846 +
847 + /// DELETE /api/items/{id}/bundle/{child_id} — remove an item from this bundle.
848 + #[tracing::instrument(skip_all, name = "items::bundle_remove")]
849 + pub async fn bundle_remove(
850 + State(state): State<AppState>,
851 + AuthUser(user): AuthUser,
852 + Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
853 + ) -> Result<impl IntoResponse> {
854 + user.check_not_suspended()?;
855 + let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?;
856 + if item.item_type != ItemType::Bundle {
857 + return Err(AppError::BadRequest("Item is not a bundle".to_string()));
858 + }
859 + db::bundles::remove_item_from_bundle(&state.db, bundle_id, child_id).await?;
860 + Ok(StatusCode::OK)
861 + }
862 +
863 + /// PUT /api/items/{id}/bundle/{child_id}/listed — toggle listed status.
864 + #[tracing::instrument(skip_all, name = "items::bundle_toggle_listed")]
865 + pub async fn bundle_toggle_listed(
866 + State(state): State<AppState>,
867 + AuthUser(user): AuthUser,
868 + Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
869 + Json(req): Json<BundleListedRequest>,
870 + ) -> Result<impl IntoResponse> {
871 + user.check_not_suspended()?;
872 + let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?;
873 + if item.item_type != ItemType::Bundle {
874 + return Err(AppError::BadRequest("Item is not a bundle".to_string()));
875 + }
876 + db::bundles::set_item_listed(&state.db, child_id, req.listed).await?;
877 + Ok(StatusCode::OK)
878 + }
@@ -190,6 +190,10 @@ pub fn api_routes() -> Router<AppState> {
190 190 .route("/api/items/bulk/unpublish", post(items::bulk_unpublish))
191 191 .route("/api/items/bulk/delete", post(items::bulk_delete))
192 192 .route("/api/items/{id}/move", put(items::move_item))
193 + // Bundle management
194 + .route("/api/items/{id}/bundle/add", post(items::bundle_add))
195 + .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove))
196 + .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed))
193 197 // Tag routes (HTMX)
194 198 .route("/api/items/{id}/tags", post(items::add_tag))
195 199 .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag))
@@ -285,18 +285,29 @@ pub(super) async fn dashboard_item(
285 285 let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
286 286 let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
287 287
288 - // Load bundle child items if this is a bundle
289 - let bundle_items = if db_item.item_type == db::ItemType::Bundle {
288 + // Load bundle child items and available items if this is a bundle
289 + let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
290 290 let children = db::bundles::get_bundle_items(&state.db, item_id).await?;
291 - children
291 + let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
292 + let child_items: Vec<Item> = children
292 293 .iter()
293 294 .map(|child| {
294 - let child_tags = vec![]; // Tags not needed for dashboard bundle list
295 + let child_tags = vec![];
295 296 Item::from_db_list(child, &child_tags, false, true)
296 297 })
297 - .collect()
298 + .collect();
299 + let available = db::bundles::get_bundleable_items(&state.db, db_project.id, Some(item_id)).await?;
300 + let available_items: Vec<Item> = available
301 + .iter()
302 + .filter(|a| !bundle_child_ids.contains(&a.id))
303 + .map(|a| {
304 + let a_tags = vec![];
305 + Item::from_db_list(a, &a_tags, false, true)
306 + })
307 + .collect();
308 + (child_items, available_items)
298 309 } else {
299 - vec![]
310 + (vec![], vec![])
300 311 };
301 312
302 313 Ok(DashboardItemTemplate {
@@ -309,6 +320,7 @@ pub(super) async fn dashboard_item(
309 320 promo_codes,
310 321 project_labels,
311 322 bundle_items,
323 + bundleable_items,
312 324 })
313 325 }
314 326
@@ -42,6 +42,29 @@ const ITEM_LABELS: &[&str] = &[
42 42 "Preview",
43 43 ];
44 44
45 + /// Whether the distribution step has actionable content for this item type.
46 + /// Only types that support license keys need the distribution step.
47 + fn needs_distribution(item_type: ItemType) -> bool {
48 + matches!(
49 + item_type,
50 + ItemType::Plugin | ItemType::Preset | ItemType::Template | ItemType::Digital | ItemType::Course
51 + )
52 + }
53 +
54 + /// Get effective steps and labels for an item type, excluding distribution when not needed.
55 + fn effective_steps(item_type: ItemType) -> (Vec<&'static str>, Vec<&'static str>) {
56 + if needs_distribution(item_type) {
57 + (ITEM_STEPS.to_vec(), ITEM_LABELS.to_vec())
58 + } else {
59 + let steps: Vec<&str> = ITEM_STEPS.iter().copied().filter(|&s| s != "distribution").collect();
60 + let labels: Vec<&str> = ITEM_STEPS.iter().zip(ITEM_LABELS.iter())
61 + .filter(|(s, _)| **s != "distribution")
62 + .map(|(_, &l)| l)
63 + .collect();
64 + (steps, labels)
65 + }
66 + }
67 +
45 68 /// Verify the user owns the project + item for wizard steps 2-6.
46 69 async fn verify_item_wizard_access(
47 70 state: &AppState,
@@ -226,7 +249,8 @@ pub async fn step_save(
226 249 .await?
227 250 .ok_or(AppError::NotFound)?;
228 251
229 - let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?;
252 + let (steps, _) = effective_steps(item.item_type);
253 + let next = super::next_step(&steps, &step).ok_or(AppError::NotFound)?;
230 254 render_step(&state, &session, &user, &project, &item, next).await
231 255 }
232 256
@@ -538,7 +562,8 @@ async fn render_step(
538 562 item: &db::DbItem,
539 563 step: &str,
540 564 ) -> Result<Response> {
541 - let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, step);
565 + let (steps, labels) = effective_steps(item.item_type);
566 + let nav = build_step_nav(&steps, &labels, step);
542 567 let project_slug = project.slug.to_string();
543 568 let item_id = item.id.to_string();
544 569 let csrf_token = get_csrf_token(session).await;
@@ -631,6 +656,8 @@ async fn render_step(
631 656 "free"
632 657 };
633 658
659 + let next = super::next_step(&steps, "pricing").unwrap_or("preview");
660 +
634 661 Ok(WizardItemPricingTemplate {
635 662 nav,
636 663 project_slug,
@@ -651,25 +678,48 @@ async fn render_step(
651 678 item.pwyw_min_cents.unwrap_or(0) / 100,
652 679 item.pwyw_min_cents.unwrap_or(0) % 100
653 680 ),
681 + next_step: next.to_string(),
654 682 }
655 683 .into_response())
656 684 }
657 685
658 686 "distribution" => {
659 - let show_license_keys = matches!(
660 - item.item_type,
661 - ItemType::Plugin
662 - | ItemType::Preset
663 - | ItemType::Template
664 - | ItemType::Digital
665 - | ItemType::Course
666 - );
687 + if !needs_distribution(item.item_type) {
688 + // Skip to preview for types without distribution options
689 + let nav = build_step_nav(&steps, &labels, "preview");
690 + let tags = db::tags::get_tags_for_item(&state.db, item.id).await?;
691 + let tag_names: Vec<String> = tags.iter().map(|t| t.tag_name.clone()).collect();
692 + return Ok(WizardItemPreviewTemplate {
693 + csrf_token,
694 + nav,
695 + project_slug,
696 + item_id,
697 + title: item.title.clone(),
698 + item_type: item.item_type.to_string(),
699 + description: item.description.clone().unwrap_or_default(),
700 + price_display: if item.pwyw_enabled {
701 + format!("PWYW (min ${}.{:02})", item.pwyw_min_cents.unwrap_or(0) / 100, item.pwyw_min_cents.unwrap_or(0) % 100)
702 + } else if item.price_cents == 0 {
703 + "Free".to_string()
704 + } else {
705 + format!("${}.{:02}", item.price_cents / 100, item.price_cents % 100)
706 + },
707 + tag_names,
708 + enable_license_keys: item.enable_license_keys,
709 + has_content: item.body.is_some()
710 + || item.audio_s3_key.is_some()
711 + || (item.item_type == ItemType::Bundle
712 + && db::bundles::get_bundle_item_count(&state.db, item.id).await.unwrap_or(0) > 0),
713 + is_public: item.is_public,
714 + back_step: "pricing".to_string(),
715 + }.into_response());
716 + }
667 717
668 718 Ok(WizardItemDistributionTemplate {
669 719 nav,
670 720 project_slug: project_slug.clone(),
671 721 item_id: item_id.clone(),
672 - show_license_keys,
722 + show_license_keys: true,
673 723 enable_license_keys: item.enable_license_keys,
674 724 max_activations: item.default_max_activations,
675 725 }
@@ -713,6 +763,11 @@ async fn render_step(
713 763 .unwrap_or(0)
714 764 > 0),
715 765 is_public: item.is_public,
766 + back_step: if needs_distribution(item.item_type) {
767 + "distribution".to_string()
768 + } else {
769 + "pricing".to_string()
770 + },
716 771 }
717 772 .into_response())
718 773 }
@@ -72,6 +72,8 @@ pub struct DashboardItemTemplate {
72 72 pub project_labels: Vec<String>,
73 73 /// Child items in this bundle (empty for non-bundle items).
74 74 pub bundle_items: Vec<Item>,
75 + /// Items available to add to this bundle (empty for non-bundle items).
76 + pub bundleable_items: Vec<Item>,
75 77 }
76 78
77 79 // ============================================================================
@@ -344,6 +346,7 @@ pub struct WizardItemPricingTemplate {
344 346 pub price_dollars: String,
345 347 pub pwyw_suggested_dollars: String,
346 348 pub pwyw_min_dollars: String,
349 + pub next_step: String,
347 350 }
348 351
349 352 #[derive(Template)]
@@ -374,4 +377,5 @@ pub struct WizardItemPreviewTemplate {
374 377 pub enable_license_keys: bool,
375 378 pub has_content: bool,
376 379 pub is_public: bool,
380 + pub back_step: String,
377 381 }
@@ -165,25 +165,140 @@
165 165
166 166 {% if item.item_type == "Bundle" %}
167 167 <!-- Bundle Contents -->
168 - <div class="content-section">
168 + <div class="content-section" id="bundle-section">
169 169 <div class="section-header">
170 - <h2>Bundle Contents ({{ bundle_items.len() }} items)</h2>
170 + <h2>Bundle Contents (<span id="bundle-count">{{ bundle_items.len() }}</span> items)</h2>
171 171 </div>
172 +
173 + <div id="bundle-items-list">
172 174 {% if bundle_items.is_empty() %}
173 - <p style="opacity: 0.7;">No items in this bundle yet. Use the item wizard to add items.</p>
175 + <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet.</p>
174 176 {% else %}
175 177 {% for child in bundle_items %}
176 - <div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border);">
178 + <div class="bundle-row" data-child-id="{{ child.id }}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);">
177 179 <span style="font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap;">{{ child.item_type }}</span>
178 180 <span style="flex: 1;">{{ child.title }}</span>
179 - {% if !child.listed %}
180 - <span style="font-size: 0.75rem; padding: 0.15rem 0.5rem; background: var(--border); opacity: 0.7;">Unlisted</span>
181 - {% endif %}
182 - <span style="font-size: 0.9rem; opacity: 0.7;">{{ child.price }}</span>
181 + <label style="font-size: 0.8rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
182 + <input type="checkbox" class="bundle-listed-toggle" data-child-id="{{ child.id }}"
183 + {% if !child.listed %}checked{% endif %}> Unlisted
184 + </label>
185 + <button type="button" class="secondary bundle-remove-btn" data-child-id="{{ child.id }}"
186 + style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Remove</button>
183 187 </div>
184 188 {% endfor %}
185 189 {% endif %}
190 + </div>
191 +
192 + {% if !bundleable_items.is_empty() %}
193 + <div style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center;">
194 + <select id="bundle-add-select" style="flex: 1; padding: 0.4rem;">
195 + <option value="">Add item to bundle...</option>
196 + {% for avail in bundleable_items %}
197 + <option value="{{ avail.id }}">{{ avail.title }} ({{ avail.item_type }})</option>
198 + {% endfor %}
199 + </select>
200 + <button type="button" class="secondary" id="bundle-add-btn" style="padding: 0.4rem 0.8rem;">Add</button>
201 + </div>
202 + {% endif %}
186 203 </div>
204 +
205 + <script>
206 + (function() {
207 + var bundleId = '{{ item.id }}';
208 +
209 + document.getElementById('bundle-add-btn')?.addEventListener('click', function() {
210 + var select = document.getElementById('bundle-add-select');
211 + var itemId = select.value;
212 + if (!itemId) return;
213 + var label = select.options[select.selectedIndex].text;
214 + this.disabled = true;
215 +
216 + fetch('/api/items/' + bundleId + '/bundle/add', {
217 + method: 'POST',
218 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
219 + body: JSON.stringify({ item_id: itemId })
220 + })
221 + .then(function(res) {
222 + if (!res.ok) throw new Error('Failed to add');
223 + // Remove from dropdown, add to list
224 + select.remove(select.selectedIndex);
225 + select.value = '';
226 + var empty = document.getElementById('bundle-empty');
227 + if (empty) empty.remove();
228 +
229 + var titleMatch = label.match(/^(.+)\s+\((.+)\)$/);
230 + var title = titleMatch ? titleMatch[1] : label;
231 + var type = titleMatch ? titleMatch[2] : '';
232 +
233 + var row = document.createElement('div');
234 + row.className = 'bundle-row';
235 + row.dataset.childId = itemId;
236 + row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);';
237 + row.innerHTML =
238 + '<span style="font-size:0.8rem;padding:0.2rem 0.6rem;background:var(--surface-muted);white-space:nowrap;">' + escapeHtml(type) + '</span>' +
239 + '<span style="flex:1;">' + escapeHtml(title) + '</span>' +
240 + '<label style="font-size:0.8rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">' +
241 + '<input type="checkbox" class="bundle-listed-toggle" data-child-id="' + itemId + '"> Unlisted</label>' +
242 + '<button type="button" class="secondary bundle-remove-btn" data-child-id="' + itemId + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Remove</button>';
243 + document.getElementById('bundle-items-list').appendChild(row);
244 + updateCount(1);
245 + attachRowHandlers(row);
246 + })
247 + .catch(function(err) { showToast(err.message); })
248 + .finally(function() { document.getElementById('bundle-add-btn').disabled = false; });
249 + });
250 +
251 + function attachRowHandlers(row) {
252 + row.querySelector('.bundle-remove-btn')?.addEventListener('click', function() {
253 + var childId = this.dataset.childId;
254 + fetch('/api/items/' + bundleId + '/bundle/' + childId, {
255 + method: 'DELETE',
256 + headers: csrfHeaders()
257 + })
258 + .then(function(res) {
259 + if (!res.ok) throw new Error('Failed to remove');
260 + // Move back to dropdown
261 + var title = row.querySelector('span[style*="flex"]').textContent;
262 + var type = row.querySelector('span[style*="surface-muted"]').textContent;
263 + var select = document.getElementById('bundle-add-select');
264 + if (select) {
265 + var opt = document.createElement('option');
266 + opt.value = childId;
267 + opt.textContent = title + ' (' + type + ')';
268 + select.appendChild(opt);
269 + }
270 + row.remove();
271 + updateCount(-1);
272 + })
273 + .catch(function(err) { showToast(err.message); });
274 + });
275 +
276 + row.querySelector('.bundle-listed-toggle')?.addEventListener('change', function() {
277 + var childId = this.dataset.childId;
278 + var listed = !this.checked;
279 + fetch('/api/items/' + bundleId + '/bundle/' + childId + '/listed', {
280 + method: 'PUT',
281 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
282 + body: JSON.stringify({ listed: listed })
283 + })
284 + .catch(function(err) { showToast(err.message); });
285 + });
286 + }
287 +
288 + document.querySelectorAll('.bundle-row').forEach(attachRowHandlers);
289 +
290 + function updateCount(delta) {
291 + var el = document.getElementById('bundle-count');
292 + el.textContent = parseInt(el.textContent) + delta;
293 + }
294 +
295 + function escapeHtml(s) {
296 + var d = document.createElement('div');
297 + d.textContent = s;
298 + return d.innerHTML;
299 + }
300 + })();
301 + </script>
187 302 {% endif %}
188 303
189 304 <!-- Pay What You Want -->
@@ -154,6 +154,18 @@
154 154 }
155 155 }
156 156
157 + function setProgressBar(idx) {
158 + var el = document.querySelector('.batch-status[data-idx="' + idx + '"]');
159 + if (!el) return {};
160 + el.style.color = '';
161 + el.innerHTML =
162 + '<div style="display:flex;align-items:center;gap:0.4rem;">' +
163 + '<div style="flex:1;height:6px;background:var(--border);border-radius:3px;overflow:hidden;min-width:60px;">' +
164 + '<div class="bp-fill" style="height:100%;width:0%;background:var(--accent,#6c5ce7);transition:width 0.15s;"></div></div>' +
165 + '<span class="bp-pct" style="font-size:0.75rem;min-width:2.5em;text-align:right;">0%</span></div>';
166 + return { percentEl: el.querySelector('.bp-pct'), progressBar: el.querySelector('.bp-fill') };
167 + }
168 +
157 169 function appendBundleRow(itemId, title, typeLabel) {
158 170 var picker = document.getElementById('bundle-picker');
159 171 var row = document.createElement('div');
@@ -205,12 +217,12 @@
205 217 entry.itemId = itemId;
206 218
207 219 // 2. Upload file
208 - setStatus(entry.idx, 'Uploading...', '');
220 + var progressEls = setProgressBar(entry.idx);
209 221 try {
210 222 if (entry.type === 'audio') {
211 - await uploadAudio(entry, itemId);
223 + await uploadAudio(entry, itemId, progressEls);
212 224 } else {
213 - await uploadVersion(entry, itemId);
225 + await uploadVersion(entry, itemId, progressEls);
214 226 }
215 227 } catch (e) {
216 228 setStatus(entry.idx, e.message || 'Upload failed', 'var(--error, #c0392b)');
@@ -227,7 +239,7 @@
227 239 try { return JSON.parse(text).error || fallback; } catch (_) { return text || fallback; }
228 240 }
229 241
230 - async function uploadAudio(entry, itemId) {
242 + async function uploadAudio(entry, itemId, progressEls) {
231 243 var file = entry.file;
232 244 var contentType = file.type || 'audio/mpeg';
233 245 var presignRes = await fetch('/api/upload/presign', {
@@ -238,7 +250,7 @@
238 250 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
239 251 var presignData = await presignRes.json();
240 252
241 - var uploader = new S3Uploader({});
253 + var uploader = new S3Uploader(progressEls);
242 254 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
243 255
244 256 var confirmRes = await fetch('/api/upload/confirm', {
@@ -249,7 +261,7 @@
249 261 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
250 262 }
251 263
252 - async function uploadVersion(entry, itemId) {
264 + async function uploadVersion(entry, itemId, progressEls) {
253 265 var file = entry.file;
254 266 var contentType = file.type || 'application/octet-stream';
255 267 var verRes = await fetch('/api/items/' + itemId + '/versions', {
@@ -269,7 +281,7 @@
269 281 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
270 282 var presignData = await presignRes.json();
271 283
272 - var uploader = new S3Uploader({});
284 + var uploader = new S3Uploader(progressEls);
273 285 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
274 286
275 287 var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', {
@@ -6,7 +6,8 @@
6 6
7 7 <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution"
8 8 hx-target="#wizard-step" hx-swap="innerHTML"
9 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview">
9 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview"
10 + novalidate>
10 11
11 12 {% if show_license_keys %}
12 13 <div class="distribution-section">
@@ -62,9 +62,9 @@
62 62
63 63 <div class="wizard-actions">
64 64 <button type="button" class="secondary"
65 - hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution"
65 + hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}"
66 66 hx-target="#wizard-step" hx-swap="innerHTML"
67 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution">Back</button>
67 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}">Back</button>
68 68 <button type="submit" name="action" value="draft" class="secondary">Save as Draft</button>
69 69 <button type="button" class="secondary"
70 70 onclick="var f=document.getElementById('schedule-fields'); f.style.display=f.style.display==='none'?'':'none';">Schedule</button>
@@ -6,7 +6,8 @@
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/distribution">
9 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ next_step }}"
10 + novalidate>
10 11
11 12 <div class="pricing-cards">
12 13 <label class="pricing-card">