Files
portal/src/trainer/card.rs
T

129 lines
4.4 KiB
Rust
Raw Normal View History

//! 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<String> = 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");
}
}