//! Item creation wizard; 6 steps: type, basics, content, sections, //! pricing, preview. mod render; mod save; use std::collections::HashMap; use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, Form, }; use tower_sessions::Session; use crate::{ auth::AuthUser, db::{self, ItemId, ItemType, PriceCents, ProjectFeature, Slug}, error::{AppError, Result}, helpers::get_csrf_token, templates::*, AppState, }; use super::build_step_nav; /// Format a price for display: "Free", "$X.XX", or "PWYW (min $X.XX)". pub(super) fn format_price_display(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option) -> String { if pwyw_enabled { let min = pwyw_min_cents.unwrap_or(0); format!("PWYW (min ${}.{:02})", min / 100, min % 100) } else if price_cents == 0 { "Free".to_string() } else { format!("${}.{:02}", price_cents / 100, price_cents % 100) } } /// Ordered step names for the item wizard. pub 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( state: &AppState, 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(&state.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(&state.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 async fn wizard_page( State(state): 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(&state.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()))?; let item = db::items::create_item( &state.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 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 async fn step_type_create( State(state): State, session: Session, AuthUser(user): AuthUser, Path(slug): Path, Form(form): Form, ) -> 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(&state.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( &state.db, project.id, "Untitled", None, PriceCents::from_db(0), item_type, db::AiTier::Handmade, None, ) .await?; // Return step 2 (basics) partial render::render_step(&state, &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 async fn step_load( State(state): State, session: Session, AuthUser(user): AuthUser, Path((slug, id, step)): Path<(String, String, String)>, ) -> Result { let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?; render::render_step(&state, &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")] pub async fn step_save( State(state): State, session: Session, AuthUser(user): AuthUser, Path((slug, id, step)): Path<(String, String, String)>, Form(form): Form>, ) -> Result { user.check_not_suspended()?; let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?; match step.as_str() { "type" => save::save_type(&state, &project, &item, &form, user.id).await?, "basics" => save::save_basics(&state, &item, &form, user.id).await?, "content" => save::save_content(&state, &item, &form, user.id).await?, "sections" => {} // Sections managed via HTMX API; pass-through "pricing" => save::save_pricing(&state, &item, &form, user.id).await?, "preview" => return save::save_preview(&state, &user, &project, &item, &form).await, _ => return Err(AppError::NotFound), } // Re-fetch item after update let item = db::items::get_item_by_id(&state.db, item.id) .await? .ok_or(AppError::NotFound)?; let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?; render::render_step(&state, &session, &user, &project, &item, next).await }