//! Item creation wizard; 6 steps: type, basics, content, sections, //! pricing, preview. mod render; mod save; use std::collections::HashMap; use axum::{ Form, extract::{Path, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::{ Integrations, auth::AuthUser, config::Config, db::{self, ItemId, ItemType, PriceCents, ProjectFeature, Slug}, error::{AppError, Result}, helpers::get_csrf_token, templates::WizardItemTemplate, }; use sqlx::PgPool; use super::build_step_nav; /// Format a price for display: "Free", "$X", or "PWYW (min $X)". Routes through /// the canonical `format_price` so the wizard preview matches the public item /// surfaces. pub(super) fn format_price_display( price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option, currency: crate::currency::SettlementCurrency, ) -> String { if pwyw_enabled { match pwyw_min_cents.unwrap_or(0) { min if min > 0 => format!( "PWYW (min {})", crate::formatting::format_price(min, currency) ), _ => "PWYW".to_string(), } } else { // format_price renders 0 as "Free". crate::formatting::format_price(price_cents, currency) } } /// Ordered step names for the item wizard's LINEAR navigation (`next_step`). /// /// Deliberately excludes `sections`: that step is managed out-of-band via the /// HTMX sections API and reached by a direct GET, not by stepping through the /// wizard, so `step_save`/`render_step` handle `"sections"` even though it never /// appears here. Keep the two in sync, a new linear step must be added here AND /// given a handler arm. pub(crate) const ITEM_STEPS: &[&str] = &["type", "basics", "content", "pricing", "preview"]; /// Human-readable labels for each step. pub(super) const ITEM_LABELS: &[&str] = &["Type", "Basics", "Content", "Pricing", "Preview"]; /// Verify the user owns the project + item for wizard steps 2-6. async fn verify_item_wizard_access( db: &PgPool, user: &crate::auth::SessionUser, project_slug: &str, item_id_str: &str, ) -> Result<(db::DbProject, db::DbItem)> { let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?; let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug) .await? .ok_or(AppError::NotFound)?; let item_id: ItemId = item_id_str.parse().map_err(|_| AppError::NotFound)?; let item = db::items::get_item_by_id(db, item_id) .await? .ok_or(AppError::NotFound)?; if item.project_id != project.id { return Err(AppError::Forbidden); } Ok((project, item)) } // Full page: GET /dashboard/project/{slug}/new-item /// Render the full item wizard page with step 1 (type) inline. /// /// If all allowed item types share the same wizard behavior (e.g. all are /// file uploads), the type step is skipped: an item is created automatically /// and the user lands on the details step. #[tracing::instrument(skip_all, name = "wizard::item_page")] pub(crate) async fn wizard_page( State(db): State, session: Session, AuthUser(user): AuthUser, Path(slug): Path, ) -> Result { let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val) .await? .ok_or(AppError::NotFound)?; let type_cards = ProjectFeature::wizard_type_cards(&project.features); // Only 1 wizard behavior group -> skip the type selector if type_cards.len() == 1 { let item_type: ItemType = type_cards[0] .0 .parse() .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?; // Reuse an existing untitled draft for this project+type rather than // minting a fresh "Untitled" row on every GET (a prefetch or re-visit // otherwise piles up orphan drafts, this is a side-effecting GET). let item = match db::items::find_untitled_wizard_draft(&db, project.id, item_type).await? { Some(existing) => existing, None => { db::items::create_item( &db, project.id, "Untitled", None, PriceCents::from_db(0), item_type, db::AiTier::Handmade, None, ) .await? } }; return Ok(axum::response::Redirect::to(&format!( "/dashboard/project/{}/new-item/{}/step/basics", slug, item.id )) .into_response()); } let csrf_token = get_csrf_token(&session).await; let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "type"); Ok(WizardItemTemplate { csrf_token, session_user: Some(user), nav, project_slug: slug, item_type_cards: type_cards, } .into_response()) } // Step 1 POST: creates the item, returns step 2 partial #[derive(serde::Deserialize)] pub(crate) struct TypeForm { pub item_type: String, } /// POST /dashboard/project/{slug}/new-item/step/type: create item, return step 2. #[tracing::instrument(skip_all, name = "wizard::item_type_create")] pub(crate) async fn step_type_create( State(db): State, session: Session, AuthUser(user): AuthUser, Path(slug): Path, headers: HeaderMap, Form(form): Form, ) -> Result { // Surface validation errors as an inline toast (HX-Reswap: none) instead of // swapping the full-page 422 into #wizard-step and wiping the selection // (ultra-fuzz Run 10 UX S2). match step_type_create_inner(db, session, user, slug, form).await { Ok(resp) => Ok(resp), Err(e) => super::wizard_validation_toast(&headers, e), } } async fn step_type_create_inner( db: PgPool, session: Session, user: crate::auth::SessionUser, slug: String, form: TypeForm, ) -> Result { user.check_not_suspended()?; let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val) .await? .ok_or(AppError::NotFound)?; let item_type: ItemType = form .item_type .parse() .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?; // Validate the selected type is in the allowed item types let cards = ProjectFeature::allowed_item_type_cards(&project.features); if !cards.iter().any(|(v, _, _)| *v == form.item_type.as_str()) { return Err(AppError::validation(format!( "Item type '{}' is not available for this project", form.item_type ))); } let item = db::items::create_item( &db, project.id, "Untitled", None, PriceCents::from_db(0), item_type, db::AiTier::Handmade, None, ) .await?; // Return step 2 (basics) partial render::render_step(&db, &session, &user, &project, &item, "basics").await } // Step GET: load a specific step partial /// GET /dashboard/project/{slug}/new-item/{id}/step/{step} #[tracing::instrument(skip_all, name = "wizard::item_step_load")] pub(crate) async fn step_load( State(db): State, session: Session, AuthUser(user): AuthUser, Path((slug, id, step)): Path<(String, String, String)>, ) -> Result { let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?; render::render_step(&db, &session, &user, &project, &item, &step).await } // Step POST: save current step, return next step partial /// POST /dashboard/project/{slug}/new-item/{id}/step/{step} #[tracing::instrument(skip_all, name = "wizard::item_step_save")] #[allow(clippy::too_many_arguments)] pub(crate) async fn step_save( State(db): State, State(mailer): State, State(config): State, State(bg): State, State(integrations): State, session: Session, AuthUser(user): AuthUser, Path((slug, id, step)): Path<(String, String, String)>, headers: HeaderMap, Form(form): Form>, ) -> Result { user.check_not_suspended()?; let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?; let save_result = match step.as_str() { "type" => save::save_type(&db, &project, &item, &form, user.id).await, "basics" => save::save_basics(&db, &item, &form, user.id).await, "content" => save::save_content(&db, &item, &form, user.id).await, "sections" => Ok(()), // Sections managed via HTMX API; pass-through "pricing" => save::save_pricing(&db, &item, &form, &user).await, "preview" => { return save::save_preview( &db, &mailer, &config, &bg, &integrations, &user, &project, &item, &form, ) .await; } _ => return Err(AppError::NotFound), }; if let Err(e) = save_result { // On an HTMX step submit, surface a validation error as an inline toast // and tell HTMX NOT to swap (`HX-Reswap: none`), otherwise the full-page // 422 error template replaces `#wizard-step` and the user loses everything // they typed in this step. Mirrors the project wizard (`wizards/project.rs`). // Non-validation errors and non-HTMX requests fall through to the normal // error response. if crate::helpers::is_htmx_request(&headers) && let AppError::Validation(ref v) = e { let mut resp = StatusCode::OK.into_response(); resp.headers_mut().insert( "HX-Trigger", crate::helpers::hx_toast(&v.to_string(), "error"), ); resp.headers_mut() .insert("HX-Reswap", HeaderValue::from_static("none")); return Ok(resp); } return Err(e); } // Re-fetch item after update let item = db::items::get_item_by_id(&db, item.id) .await? .ok_or(AppError::NotFound)?; let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?; render::render_step(&db, &session, &user, &project, &item, next).await }