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