//! An alternative as the visitor meets it, in text. //! //! Portal renders a card: the heading, the description, a button, one //! or two encouragements, and for each section an icon, a title, a //! line of guidance and the fields with their labels and placeholders. //! A visitor is oriented by all of it, not the heading alone. The //! answering visitor and `rate` both read this rendering, so the //! scored page is the served page; the gutted twin keeps the card's //! shape and none of its words. use serde_json::Value; pub fn s(v: &Value) -> &str { v.as_str().unwrap_or_default() } /// One option on a page. `full` is the card as rendered; otherwise the /// heading alone, which is what a visitor who reads nothing else sees. pub fn alternative(alt: &Value, full: bool) -> String { let mut out = format!("- {}", s(&alt["name"])); if !full { out.push('\n'); return out; } let d = s(&alt["description"]).trim(); if !d.is_empty() { out.push_str(&format!(": {d}")); } out.push('\n'); if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) { out.push_str(&format!(" Button: {button}\n")); } for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) { out.push_str(&format!(" {e}\n")); } for f in alt["features"].as_array().into_iter().flatten() { let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim()); let mut line = String::from(" "); if !icon.is_empty() { line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon))); } if !name.is_empty() { line.push_str(name); } if !desc.is_empty() { line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " })); } if !line.trim().is_empty() { out.push_str(line.trim_end()); out.push('\n'); } let fields: Vec = f["requirements"] .as_array() .into_iter() .flatten() .map(|r| { let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"])); let hint = s(&r["placeholder"]).trim(); let opt = if r["optional"] == true { " (optional)" } else { "" }; if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") } }) .collect(); if !fields.is_empty() { out.push_str(&format!(" Asks for: {}\n", fields.join("; "))); } } out } /// The page around its options: what it asks and says of itself. pub fn page_head(page: &Value) -> String { let mut out = format!("{}\n", s(&page["name"])); let d = s(&page["description"]).trim(); if !d.is_empty() { out.push_str(&format!("{d}\n")); } out } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() { let alt = json!({ "name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.", "requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"}, {"name": "address", "label": "Address", "optional": true}]}] }); let full = alternative(&alt, true); assert!(full.starts_with("- Get a price: For owners.\n")); assert!(full.contains("Button: Send it") && full.contains("We answer within two days.")); assert!(full.contains("[icon file-text] Your brief: A few lines are enough.")); assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)")); assert_eq!(alternative(&alt, false), "- Get a price\n"); } }