max / makenotwork
4 files changed,
+51 insertions,
-31 deletions
| @@ -50,12 +50,15 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I | |||
| 50 | 50 | return Err(AppError::NotFound); | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | // Use the canonical formatter so the embed card matches every other surface | |
| 54 | + | // (thousands separators, whole-dollar shortening) instead of an f64-divided | |
| 55 | + | // "$12345.00" (Run #2 UX SERIOUS — price fragmentation). | |
| 53 | 56 | let (price_display, button_text) = if item.price_cents == 0 { | |
| 54 | 57 | ("Free".to_string(), "Get".to_string()) | |
| 55 | 58 | } else if item.pwyw_enabled { | |
| 56 | - | (format!("${:.2}+", item.price_cents as f64 / 100.0), "Buy".to_string()) | |
| 59 | + | (format!("{}+", crate::formatting::format_price(item.price_cents)), "Buy".to_string()) | |
| 57 | 60 | } else { | |
| 58 | - | (format!("${:.2}", item.price_cents as f64 / 100.0), "Buy".to_string()) | |
| 61 | + | (crate::formatting::format_price(item.price_cents), "Buy".to_string()) | |
| 59 | 62 | }; | |
| 60 | 63 | ||
| 61 | 64 | let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id); |
| @@ -25,19 +25,28 @@ use crate::{ | |||
| 25 | 25 | ||
| 26 | 26 | use super::build_step_nav; | |
| 27 | 27 | ||
| 28 | - | /// Format a price for display: "Free", "$X.XX", or "PWYW (min $X.XX)". | |
| 28 | + | /// Format a price for display: "Free", "$X", or "PWYW (min $X)". Routes through | |
| 29 | + | /// the canonical `format_price` so the wizard preview matches the public item | |
| 30 | + | /// surfaces (Run #2 UX SERIOUS — price fragmentation). | |
| 29 | 31 | pub(super) fn format_price_display(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option<i32>) -> String { | |
| 30 | 32 | if pwyw_enabled { | |
| 31 | - | let min = pwyw_min_cents.unwrap_or(0); | |
| 32 | - | format!("PWYW (min ${}.{:02})", min / 100, min % 100) | |
| 33 | - | } else if price_cents == 0 { | |
| 34 | - | "Free".to_string() | |
| 33 | + | match pwyw_min_cents.unwrap_or(0) { | |
| 34 | + | min if min > 0 => format!("PWYW (min {})", crate::formatting::format_price(min)), | |
| 35 | + | _ => "PWYW".to_string(), | |
| 36 | + | } | |
| 35 | 37 | } else { | |
| 36 | - | format!("${}.{:02}", price_cents / 100, price_cents % 100) | |
| 38 | + | // format_price renders 0 as "Free". | |
| 39 | + | crate::formatting::format_price(price_cents) | |
| 37 | 40 | } | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | - | /// Ordered step names for the item wizard. | |
| 43 | + | /// Ordered step names for the item wizard's LINEAR navigation (`next_step`). | |
| 44 | + | /// | |
| 45 | + | /// Deliberately excludes `sections`: that step is managed out-of-band via the | |
| 46 | + | /// HTMX sections API and reached by a direct GET, not by stepping through the | |
| 47 | + | /// wizard, so `step_save`/`render_step` handle `"sections"` even though it never | |
| 48 | + | /// appears here. Keep the two in sync — a new linear step must be added here AND | |
| 49 | + | /// given a handler arm (Run #2 UX MINOR). | |
| 41 | 50 | pub const ITEM_STEPS: &[&str] = &[ | |
| 42 | 51 | "type", | |
| 43 | 52 | "basics", |
| @@ -281,27 +281,32 @@ pub(super) async fn save_preview( | |||
| 281 | 281 | } | |
| 282 | 282 | } | |
| 283 | 283 | "schedule" => { | |
| 284 | - | if let Some(datetime_str) = form.get("publish_at") | |
| 285 | - | && let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M") | |
| 286 | - | { | |
| 287 | - | let utc_dt = dt.and_utc(); | |
| 288 | - | db::items::update_item( | |
| 289 | - | &state.db, | |
| 290 | - | item.id, | |
| 291 | - | user.id, | |
| 292 | - | None, | |
| 293 | - | None, | |
| 294 | - | None, | |
| 295 | - | None, | |
| 296 | - | None, | |
| 297 | - | None, | |
| 298 | - | None, | |
| 299 | - | Some(Some(utc_dt)), | |
| 300 | - | None, | |
| 301 | - | None, None, // ai_tier, ai_disclosure | |
| 302 | - | ) | |
| 303 | - | .await?; | |
| 304 | - | } | |
| 284 | + | // A missing or unparseable publish_at used to fall through silently, | |
| 285 | + | // leaving the item a draft while the creator believed it was scheduled | |
| 286 | + | // (Run #2 UX MINOR). Surface a validation error instead. | |
| 287 | + | let datetime_str = form | |
| 288 | + | .get("publish_at") | |
| 289 | + | .filter(|s| !s.is_empty()) | |
| 290 | + | .ok_or_else(|| AppError::validation("Enter a publish date and time to schedule this item.".to_string()))?; | |
| 291 | + | let dt = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M") | |
| 292 | + | .map_err(|_| AppError::validation("Enter a valid publish date and time.".to_string()))?; | |
| 293 | + | let utc_dt = dt.and_utc(); | |
| 294 | + | db::items::update_item( | |
| 295 | + | &state.db, | |
| 296 | + | item.id, | |
| 297 | + | user.id, | |
| 298 | + | None, | |
| 299 | + | None, | |
| 300 | + | None, | |
| 301 | + | None, | |
| 302 | + | None, | |
| 303 | + | None, | |
| 304 | + | None, | |
| 305 | + | Some(Some(utc_dt)), | |
| 306 | + | None, | |
| 307 | + | None, None, // ai_tier, ai_disclosure | |
| 308 | + | ) | |
| 309 | + | .await?; | |
| 305 | 310 | } | |
| 306 | 311 | _ => {} // draft -- leave as is | |
| 307 | 312 | } |
| @@ -33,7 +33,10 @@ pub(super) async fn feed_page( | |||
| 33 | 33 | let csrf_token = get_csrf_token(&session).await; | |
| 34 | 34 | let session_user = Some(user.clone()); | |
| 35 | 35 | ||
| 36 | - | let page = query.page.unwrap_or(1).max(1); | |
| 36 | + | // Clamp the upper bound too (matches admin/git pagination); the i64 widening | |
| 37 | + | // below already prevents the overflow panic, but an unbounded page is a | |
| 38 | + | // pointless huge offset (Run #2 UX MINOR). | |
| 39 | + | let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); | |
| 37 | 40 | // Widen to i64 BEFORE multiplying — `(page - 1) * FEED_PAGE_SIZE` in u32 | |
| 38 | 41 | // overflows for a large `?page=` (garbage offset in release, panic in debug). | |
| 39 | 42 | let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; |