2026-09-23 12:31:51 +02:00
|
|
|
//! 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');
|
2026-10-07 11:18:46 +02:00
|
|
|
if let Some(button) = alt["consequence"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.and_then(|c| c.first())
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
{
|
2026-09-23 12:31:51 +02:00
|
|
|
out.push_str(&format!(" Button: {button}\n"));
|
|
|
|
|
}
|
2026-10-07 11:18:46 +02:00
|
|
|
for e in alt["encouragements"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flatten()
|
|
|
|
|
.filter_map(Value::as_str)
|
|
|
|
|
{
|
2026-09-23 12:31:51 +02:00
|
|
|
out.push_str(&format!(" {e}\n"));
|
|
|
|
|
}
|
|
|
|
|
for f in alt["features"].as_array().into_iter().flatten() {
|
2026-10-07 11:18:46 +02:00
|
|
|
let (name, desc, icon) = (
|
|
|
|
|
s(&f["name"]).trim(),
|
|
|
|
|
s(&f["description"]).trim(),
|
|
|
|
|
s(&f["icon"]).trim(),
|
|
|
|
|
);
|
2026-09-23 12:31:51 +02:00
|
|
|
let mut line = String::from(" ");
|
|
|
|
|
if !icon.is_empty() {
|
2026-10-07 11:18:46 +02:00
|
|
|
line.push_str(&format!(
|
|
|
|
|
"[icon {}] ",
|
|
|
|
|
icon.rsplit(':').next().unwrap_or(icon)
|
|
|
|
|
));
|
2026-09-23 12:31:51 +02:00
|
|
|
}
|
|
|
|
|
if !name.is_empty() {
|
|
|
|
|
line.push_str(name);
|
|
|
|
|
}
|
|
|
|
|
if !desc.is_empty() {
|
2026-10-07 11:18:46 +02:00
|
|
|
line.push_str(&format!(
|
|
|
|
|
"{}{desc}",
|
|
|
|
|
if name.is_empty() { "" } else { ": " }
|
|
|
|
|
));
|
2026-09-23 12:31:51 +02:00
|
|
|
}
|
|
|
|
|
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();
|
2026-10-07 11:18:46 +02:00
|
|
|
let opt = if r["optional"] == true {
|
|
|
|
|
" (optional)"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
|
|
|
|
if hint.is_empty() {
|
|
|
|
|
format!("{label}{opt}")
|
|
|
|
|
} else {
|
|
|
|
|
format!("{label}{opt} e.g. {hint}")
|
|
|
|
|
}
|
2026-09-23 12:31:51 +02:00
|
|
|
})
|
|
|
|
|
.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");
|
|
|
|
|
}
|
|
|
|
|
}
|