Skip to main content

max / makenotwork

10.4 KB · 310 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 Form,
11 extract::{Path, State},
12 http::{HeaderMap, HeaderValue, StatusCode},
13 response::{IntoResponse, Response},
14 };
15 use tower_sessions::Session;
16
17 use crate::{
18 Integrations,
19 auth::AuthUser,
20 config::Config,
21 db::{self, ItemId, ItemType, PriceCents, ProjectFeature, Slug},
22 error::{AppError, Result},
23 helpers::get_csrf_token,
24 templates::WizardItemTemplate,
25 };
26 use sqlx::PgPool;
27
28 use super::build_step_nav;
29
30 /// Format a price for display: "Free", "$X", or "PWYW (min $X)". Routes through
31 /// the canonical `format_price` so the wizard preview matches the public item
32 /// surfaces (Run #2 UX SERIOUS, price fragmentation).
33 pub(super) fn format_price_display(
34 price_cents: i32,
35 pwyw_enabled: bool,
36 pwyw_min_cents: Option<i32>,
37 ) -> String {
38 if pwyw_enabled {
39 match pwyw_min_cents.unwrap_or(0) {
40 min if min > 0 => format!("PWYW (min {})", crate::formatting::format_price(min)),
41 _ => "PWYW".to_string(),
42 }
43 } else {
44 // format_price renders 0 as "Free".
45 crate::formatting::format_price(price_cents)
46 }
47 }
48
49 /// Ordered step names for the item wizard's LINEAR navigation (`next_step`).
50 ///
51 /// Deliberately excludes `sections`: that step is managed out-of-band via the
52 /// HTMX sections API and reached by a direct GET, not by stepping through the
53 /// wizard, so `step_save`/`render_step` handle `"sections"` even though it never
54 /// appears here. Keep the two in sync, a new linear step must be added here AND
55 /// given a handler arm (Run #2 UX MINOR).
56 pub(crate) const ITEM_STEPS: &[&str] = &["type", "basics", "content", "pricing", "preview"];
57
58 /// Human-readable labels for each step.
59 pub(super) const ITEM_LABELS: &[&str] = &["Type", "Basics", "Content", "Pricing", "Preview"];
60
61 /// Verify the user owns the project + item for wizard steps 2-6.
62 async fn verify_item_wizard_access(
63 db: &PgPool,
64 user: &crate::auth::SessionUser,
65 project_slug: &str,
66 item_id_str: &str,
67 ) -> Result<(db::DbProject, db::DbItem)> {
68 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
69 let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug)
70 .await?
71 .ok_or(AppError::NotFound)?;
72
73 let item_id: ItemId = item_id_str.parse().map_err(|_| AppError::NotFound)?;
74 let item = db::items::get_item_by_id(db, item_id)
75 .await?
76 .ok_or(AppError::NotFound)?;
77
78 if item.project_id != project.id {
79 return Err(AppError::Forbidden);
80 }
81
82 Ok((project, item))
83 }
84
85 // Full page: GET /dashboard/project/{slug}/new-item
86
87 /// Render the full item wizard page with step 1 (type) inline.
88 ///
89 /// If all allowed item types share the same wizard behavior (e.g. all are
90 /// file uploads), the type step is skipped: an item is created automatically
91 /// and the user lands on the details step.
92 #[tracing::instrument(skip_all, name = "wizard::item_page")]
93 pub(crate) async fn wizard_page(
94 State(db): State<PgPool>,
95 session: Session,
96 AuthUser(user): AuthUser,
97 Path(slug): Path<String>,
98 ) -> Result<Response> {
99 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
100 let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
101 .await?
102 .ok_or(AppError::NotFound)?;
103
104 let type_cards = ProjectFeature::wizard_type_cards(&project.features);
105
106 // Only 1 wizard behavior group -> skip the type selector
107 if type_cards.len() == 1 {
108 let item_type: ItemType = type_cards[0]
109 .0
110 .parse()
111 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
112
113 // Reuse an existing untitled draft for this project+type rather than
114 // minting a fresh "Untitled" row on every GET (a prefetch or re-visit
115 // otherwise piles up orphan drafts, this is a side-effecting GET).
116 let item = match db::items::find_untitled_wizard_draft(&db, project.id, item_type).await? {
117 Some(existing) => existing,
118 None => {
119 db::items::create_item(
120 &db,
121 project.id,
122 "Untitled",
123 None,
124 PriceCents::from_db(0),
125 item_type,
126 db::AiTier::Handmade,
127 None,
128 )
129 .await?
130 }
131 };
132
133 return Ok(axum::response::Redirect::to(&format!(
134 "/dashboard/project/{}/new-item/{}/step/basics",
135 slug, item.id
136 ))
137 .into_response());
138 }
139
140 let csrf_token = get_csrf_token(&session).await;
141 let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "type");
142
143 Ok(WizardItemTemplate {
144 csrf_token,
145 session_user: Some(user),
146 nav,
147 project_slug: slug,
148 item_type_cards: type_cards,
149 }
150 .into_response())
151 }
152
153 // Step 1 POST: creates the item, returns step 2 partial
154
155 #[derive(serde::Deserialize)]
156 pub(crate) struct TypeForm {
157 pub item_type: String,
158 }
159
160 /// POST /dashboard/project/{slug}/new-item/step/type: create item, return step 2.
161 #[tracing::instrument(skip_all, name = "wizard::item_type_create")]
162 pub(crate) async fn step_type_create(
163 State(db): State<PgPool>,
164 session: Session,
165 AuthUser(user): AuthUser,
166 Path(slug): Path<String>,
167 headers: HeaderMap,
168 Form(form): Form<TypeForm>,
169 ) -> Result<Response> {
170 // Surface validation errors as an inline toast (HX-Reswap: none) instead of
171 // swapping the full-page 422 into #wizard-step and wiping the selection
172 // (ultra-fuzz Run 10 UX S2).
173 match step_type_create_inner(db, session, user, slug, form).await {
174 Ok(resp) => Ok(resp),
175 Err(e) => super::wizard_validation_toast(&headers, e),
176 }
177 }
178
179 async fn step_type_create_inner(
180 db: PgPool,
181 session: Session,
182 user: crate::auth::SessionUser,
183 slug: String,
184 form: TypeForm,
185 ) -> Result<Response> {
186 user.check_not_suspended()?;
187
188 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
189 let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
190 .await?
191 .ok_or(AppError::NotFound)?;
192
193 let item_type: ItemType = form
194 .item_type
195 .parse()
196 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
197
198 // Validate the selected type is in the allowed item types
199 let cards = ProjectFeature::allowed_item_type_cards(&project.features);
200 if !cards.iter().any(|(v, _, _)| *v == form.item_type.as_str()) {
201 return Err(AppError::validation(format!(
202 "Item type '{}' is not available for this project",
203 form.item_type
204 )));
205 }
206
207 let item = db::items::create_item(
208 &db,
209 project.id,
210 "Untitled",
211 None,
212 PriceCents::from_db(0),
213 item_type,
214 db::AiTier::Handmade,
215 None,
216 )
217 .await?;
218
219 // Return step 2 (basics) partial
220 render::render_step(&db, &session, &user, &project, &item, "basics").await
221 }
222
223 // Step GET: load a specific step partial
224
225 /// GET /dashboard/project/{slug}/new-item/{id}/step/{step}
226 #[tracing::instrument(skip_all, name = "wizard::item_step_load")]
227 pub(crate) async fn step_load(
228 State(db): State<PgPool>,
229 session: Session,
230 AuthUser(user): AuthUser,
231 Path((slug, id, step)): Path<(String, String, String)>,
232 ) -> Result<Response> {
233 let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?;
234 render::render_step(&db, &session, &user, &project, &item, &step).await
235 }
236
237 // Step POST: save current step, return next step partial
238
239 /// POST /dashboard/project/{slug}/new-item/{id}/step/{step}
240 #[tracing::instrument(skip_all, name = "wizard::item_step_save")]
241 #[allow(clippy::too_many_arguments)]
242 pub(crate) async fn step_save(
243 State(db): State<PgPool>,
244 State(mailer): State<crate::email::EmailClient>,
245 State(config): State<Config>,
246 State(bg): State<crate::background::BackgroundTx>,
247 State(integrations): State<Integrations>,
248 session: Session,
249 AuthUser(user): AuthUser,
250 Path((slug, id, step)): Path<(String, String, String)>,
251 headers: HeaderMap,
252 Form(form): Form<HashMap<String, String>>,
253 ) -> Result<Response> {
254 user.check_not_suspended()?;
255 let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?;
256
257 let save_result = match step.as_str() {
258 "type" => save::save_type(&db, &project, &item, &form, user.id).await,
259 "basics" => save::save_basics(&db, &item, &form, user.id).await,
260 "content" => save::save_content(&db, &item, &form, user.id).await,
261 "sections" => Ok(()), // Sections managed via HTMX API; pass-through
262 "pricing" => save::save_pricing(&db, &item, &form, user.id).await,
263 "preview" => {
264 return save::save_preview(
265 &db,
266 &mailer,
267 &config,
268 &bg,
269 &integrations,
270 &user,
271 &project,
272 &item,
273 &form,
274 )
275 .await;
276 }
277 _ => return Err(AppError::NotFound),
278 };
279
280 if let Err(e) = save_result {
281 // On an HTMX step submit, surface a validation error as an inline toast
282 // and tell HTMX NOT to swap (`HX-Reswap: none`), otherwise the full-page
283 // 422 error template replaces `#wizard-step` and the user loses everything
284 // they typed in this step. Mirrors the project wizard (`wizards/project.rs`).
285 // Non-validation errors and non-HTMX requests fall through to the normal
286 // error response.
287 if crate::helpers::is_htmx_request(&headers)
288 && let AppError::Validation(ref v) = e
289 {
290 let mut resp = StatusCode::OK.into_response();
291 resp.headers_mut().insert(
292 "HX-Trigger",
293 crate::helpers::hx_toast(&v.to_string(), "error"),
294 );
295 resp.headers_mut()
296 .insert("HX-Reswap", HeaderValue::from_static("none"));
297 return Ok(resp);
298 }
299 return Err(e);
300 }
301
302 // Re-fetch item after update
303 let item = db::items::get_item_by_id(&db, item.id)
304 .await?
305 .ok_or(AppError::NotFound)?;
306
307 let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?;
308 render::render_step(&db, &session, &user, &project, &item, next).await
309 }
310