From e237a4b3c023237c1cbe58ad625564265e247917 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 12 Aug 2026 23:09:55 +0200 Subject: [PATCH] Question nav + gateway alternatives - QuestionNav: every other question the visitor currently qualifies for, rendered under each page's alternatives - a concern reaches the people it speaks to without claiming front-page space, and an owner sees the gated desks in the same nav. Context-dependent by session (list_qualifying_questions filters via is_qualified). - An alternative with an action but nothing to submit, confirm, or record is a gateway: its button now renders as a real link instead of a form submit that did nothing. Co-Authored-By: Claude Sonnet 5 --- src/app.rs | 129 ++++++++++++++++++++++++++++++++++++++++--------- style/main.css | 30 ++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/src/app.rs b/src/app.rs index 36ff592..9334160 100644 --- a/src/app.rs +++ b/src/app.rs @@ -196,19 +196,23 @@ fn QuestionView( } } key=|a| a.name.clone() - children=move |alt: Alternative| { - view! { - + children={ + let question_id = question_id.clone(); + move |alt: Alternative| { + view! { + + } + .into_any() } - .into_any() } /> + {question .responsible .clone() @@ -219,6 +223,39 @@ fn QuestionView( .into_any() } +/// Every other question the visitor currently qualifies for, as a +/// small nav - how a concern reaches the people it speaks to without +/// claiming front-page space (an owner also sees the gated desks here). +#[component] +fn QuestionNav(current_id: String) -> impl IntoView { + let nav = Resource::new(|| (), |_| list_qualifying_questions()); + view! { + + {move || { + let current_id = current_id.clone(); + nav.get().and_then(|res| res.ok()).map(|items| { + let others: Vec<(String, String)> = items + .into_iter() + .filter(|(id, _)| *id != current_id) + .collect(); + (!others.is_empty()).then(|| view! { + + }) + }) + }} + + } +} + /// "Asked by X — contact them if you get stuck." /// The mailto address is assembled from `data-user`/`data-domain` on a /// real mouse event, never baked into the server-rendered `href` - @@ -463,6 +500,16 @@ fn AlternativeCard( let has_action = alternative.action.is_some(); let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty()); + // Nothing to submit, nothing to confirm, nothing to record: the + // alternative is a gateway to another question, and its button is + // just a link (a form submit would be a click that does nothing). + let is_gateway = has_action + && !has_requirements + && alternative.record_as.is_none() + && alternative + .features + .iter() + .all(|f| f.resource.as_ref().is_none_or(|r| r.transitions.is_empty())); // Shared across every resource feature on this alternative: which // row(s) have a transition selected but not yet confirmed, keyed by @@ -841,19 +888,30 @@ fn AlternativeCard( children=move |e| view! {

{e}

} /> -
- -
+ {if is_gateway { + let href = alternative.action.clone().unwrap_or_default(); + view! { + {button_label.clone()} + } + .into_any() + } else { + view! { +
+ +
+ } + .into_any() + }} } })} @@ -1372,6 +1430,33 @@ pub async fn get_question(path: String) -> Result, ServerFnErro Ok(state.questions.load().get(&path).cloned()) } +/// Every question the current visitor qualifies for, as (id, name) - +/// the site nav's data. Context-dependent: an owner session sees the +/// gated pages too, an anonymous visitor only the public ones. +#[server] +pub async fn list_qualifying_questions() -> Result, ServerFnError> { + use crate::auth::{User, SESSION_USER_KEY}; + use crate::content::is_qualified; + use crate::server::AppState; + + let state = expect_context::(); + let session: tower_sessions::Session = leptos_axum::extract().await?; + let user = session + .get::(SESSION_USER_KEY) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let mut out: Vec<(String, String)> = state + .questions + .load() + .values() + .filter(|q| is_qualified(user.as_ref(), q)) + .map(|q| (q.id.clone(), q.name.clone())) + .collect(); + out.sort(); + Ok(out) +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SubmitResult { pub next: Option, diff --git a/style/main.css b/style/main.css index f4372fd..f4c9344 100644 --- a/style/main.css +++ b/style/main.css @@ -239,6 +239,36 @@ main.not-found { } } +.question-nav { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: baseline; + gap: 0.5rem 1.2rem; + margin: 2.2rem auto 0; + max-width: 44rem; + padding: 0 1rem; + font-size: 0.88rem; +} + +.question-nav-lead { + color: var(--ink-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.72rem; +} + +.question-nav a { + color: var(--ink-dim); + text-decoration: none; + border-bottom: 1px solid var(--line); +} + +.question-nav a:hover { + color: var(--accent); + border-bottom-color: var(--accent); +} + .question-responsible { text-align: center; color: var(--ink-dim);