Skip to main content

max / makenotwork

7.9 KB · 248 lines History Blame Raw
1 //! Item creation wizard; 6 steps: type, basics, content, sections,
2 //! pricing, preview.
3
4 mod render;
5 mod save;
6
7 use std::collections::HashMap;
8
9 use axum::{
10 extract::{Path, State},
11 response::{IntoResponse, Response},
12 Form,
13 };
14 use tower_sessions::Session;
15
16 use crate::{
17 auth::AuthUser,
18 db::{self, ItemId, ItemType, PriceCents, ProjectFeature, Slug},
19 error::{AppError, Result},
20 helpers::get_csrf_token,
21 templates::*,
22 AppState,
23 };
24
25 use super::build_step_nav;
26
27 /// Format a price for display: "Free", "$X.XX", or "PWYW (min $X.XX)".
28 pub(super) fn format_price_display(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option<i32>) -> String {
29 if pwyw_enabled {
30 let min = pwyw_min_cents.unwrap_or(0);
31 format!("PWYW (min ${}.{:02})", min / 100, min % 100)
32 } else if price_cents == 0 {
33 "Free".to_string()
34 } else {
35 format!("${}.{:02}", price_cents / 100, price_cents % 100)
36 }
37 }
38
39 /// Ordered step names for the item wizard.
40 pub const ITEM_STEPS: &[&str] = &[
41 "type",
42 "basics",
43 "content",
44 "pricing",
45 "preview",
46 ];
47
48 /// Human-readable labels for each step.
49 pub(super) const ITEM_LABELS: &[&str] = &[
50 "Type",
51 "Basics",
52 "Content",
53 "Pricing",
54 "Preview",
55 ];
56
57 /// Verify the user owns the project + item for wizard steps 2-6.
58 async fn verify_item_wizard_access(
59 state: &AppState,
60 user: &crate::auth::SessionUser,
61 project_slug: &str,
62 item_id_str: &str,
63 ) -> Result<(db::DbProject, db::DbItem)> {
64 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
65 let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug)
66 .await?
67 .ok_or(AppError::NotFound)?;
68
69 let item_id: ItemId = item_id_str.parse().map_err(|_| AppError::NotFound)?;
70 let item = db::items::get_item_by_id(&state.db, item_id)
71 .await?
72 .ok_or(AppError::NotFound)?;
73
74 if item.project_id != project.id {
75 return Err(AppError::Forbidden);
76 }
77
78 Ok((project, item))
79 }
80
81 // =============================================================================
82 // Full page: GET /dashboard/project/{slug}/new-item
83 // =============================================================================
84
85 /// Render the full item wizard page with step 1 (type) inline.
86 ///
87 /// If all allowed item types share the same wizard behavior (e.g. all are
88 /// file uploads), the type step is skipped: an item is created automatically
89 /// and the user lands on the details step.
90 #[tracing::instrument(skip_all, name = "wizard::item_page")]
91 pub async fn wizard_page(
92 State(state): State<AppState>,
93 session: Session,
94 AuthUser(user): AuthUser,
95 Path(slug): Path<String>,
96 ) -> Result<Response> {
97 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
98 let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug_val)
99 .await?
100 .ok_or(AppError::NotFound)?;
101
102 let type_cards = ProjectFeature::wizard_type_cards(&project.features);
103
104 // Only 1 wizard behavior group -> skip the type selector
105 if type_cards.len() == 1 {
106 let item_type: ItemType = type_cards[0]
107 .0
108 .parse()
109 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
110
111 let item = db::items::create_item(
112 &state.db,
113 project.id,
114 "Untitled",
115 None,
116 PriceCents::from_db(0),
117 item_type,
118 db::AiTier::Handmade,
119 None,
120 )
121 .await?;
122
123 return Ok(axum::response::Redirect::to(&format!(
124 "/dashboard/project/{}/new-item/{}/step/basics",
125 slug, item.id
126 ))
127 .into_response());
128 }
129
130 let csrf_token = get_csrf_token(&session).await;
131 let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "type");
132
133 Ok(WizardItemTemplate {
134 csrf_token,
135 session_user: Some(user),
136 nav,
137 project_slug: slug,
138 item_type_cards: type_cards,
139 }
140 .into_response())
141 }
142
143 // =============================================================================
144 // Step 1 POST: creates the item, returns step 2 partial
145 // =============================================================================
146
147 #[derive(serde::Deserialize)]
148 pub struct TypeForm {
149 pub item_type: String,
150 }
151
152 /// POST /dashboard/project/{slug}/new-item/step/type: create item, return step 2.
153 #[tracing::instrument(skip_all, name = "wizard::item_type_create")]
154 pub async fn step_type_create(
155 State(state): State<AppState>,
156 session: Session,
157 AuthUser(user): AuthUser,
158 Path(slug): Path<String>,
159 Form(form): Form<TypeForm>,
160 ) -> Result<Response> {
161 user.check_not_suspended()?;
162
163 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
164 let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug_val)
165 .await?
166 .ok_or(AppError::NotFound)?;
167
168 let item_type: ItemType = form
169 .item_type
170 .parse()
171 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
172
173 // Validate the selected type is in the allowed item types
174 let cards = ProjectFeature::allowed_item_type_cards(&project.features);
175 if !cards.iter().any(|(v, _, _)| *v == form.item_type.as_str()) {
176 return Err(AppError::validation(format!(
177 "Item type '{}' is not available for this project",
178 form.item_type
179 )));
180 }
181
182 let item = db::items::create_item(
183 &state.db,
184 project.id,
185 "Untitled",
186 None,
187 PriceCents::from_db(0),
188 item_type,
189 db::AiTier::Handmade,
190 None,
191 )
192 .await?;
193
194 // Return step 2 (basics) partial
195 render::render_step(&state, &session, &user, &project, &item, "basics").await
196 }
197
198 // =============================================================================
199 // Step GET: load a specific step partial
200 // =============================================================================
201
202 /// GET /dashboard/project/{slug}/new-item/{id}/step/{step}
203 #[tracing::instrument(skip_all, name = "wizard::item_step_load")]
204 pub async fn step_load(
205 State(state): State<AppState>,
206 session: Session,
207 AuthUser(user): AuthUser,
208 Path((slug, id, step)): Path<(String, String, String)>,
209 ) -> Result<Response> {
210 let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?;
211 render::render_step(&state, &session, &user, &project, &item, &step).await
212 }
213
214 // =============================================================================
215 // Step POST: save current step, return next step partial
216 // =============================================================================
217
218 /// POST /dashboard/project/{slug}/new-item/{id}/step/{step}
219 #[tracing::instrument(skip_all, name = "wizard::item_step_save")]
220 pub async fn step_save(
221 State(state): State<AppState>,
222 session: Session,
223 AuthUser(user): AuthUser,
224 Path((slug, id, step)): Path<(String, String, String)>,
225 Form(form): Form<HashMap<String, String>>,
226 ) -> Result<Response> {
227 user.check_not_suspended()?;
228 let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?;
229
230 match step.as_str() {
231 "type" => save::save_type(&state, &project, &item, &form, user.id).await?,
232 "basics" => save::save_basics(&state, &item, &form, user.id).await?,
233 "content" => save::save_content(&state, &item, &form, user.id).await?,
234 "sections" => {} // Sections managed via HTMX API; pass-through
235 "pricing" => save::save_pricing(&state, &item, &form, user.id).await?,
236 "preview" => return save::save_preview(&state, &user, &project, &item, &form).await,
237 _ => return Err(AppError::NotFound),
238 }
239
240 // Re-fetch item after update
241 let item = db::items::get_item_by_id(&state.db, item.id)
242 .await?
243 .ok_or(AppError::NotFound)?;
244
245 let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?;
246 render::render_step(&state, &session, &user, &project, &item, next).await
247 }
248