Question nav + gateway alternatives
Deploy / deploy (push) Successful in 1m1s

- 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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 23:09:55 +02:00
co-authored by Claude Sonnet 5
parent 4d71b6ccd0
commit e237a4b3c0
2 changed files with 137 additions and 22 deletions
+107 -22
View File
@@ -196,19 +196,23 @@ fn QuestionView(
} }
} }
key=|a| a.name.clone() key=|a| a.name.clone()
children=move |alt: Alternative| { children={
view! { let question_id = question_id.clone();
<AlternativeCard move |alt: Alternative| {
question_id=question_id.clone() view! {
alternative=alt <AlternativeCard
parent_hash=parent_hash.clone() question_id=question_id.clone()
query_email=query_email.clone() alternative=alt
/> parent_hash=parent_hash.clone()
query_email=query_email.clone()
/>
}
.into_any()
} }
.into_any()
} }
/> />
</div> </div>
<QuestionNav current_id=question_id/>
{question {question
.responsible .responsible
.clone() .clone()
@@ -219,6 +223,39 @@ fn QuestionView(
.into_any() .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! {
<Suspense fallback=|| ()>
{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! {
<nav class="question-nav">
<span class="question-nav-lead">"Also worth asking"</span>
<For
each=move || others.clone()
key=|(id, _)| id.clone()
children=|(id, name): (String, String)| {
view! { <a href=id>{name}</a> }.into_any()
}
/>
</nav>
})
})
}}
</Suspense>
}
}
/// "Asked by X — contact them if you get stuck." /// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a /// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` - /// 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_action = alternative.action.is_some();
let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty()); 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 // Shared across every resource feature on this alternative: which
// row(s) have a transition selected but not yet confirmed, keyed by // row(s) have a transition selected but not yet confirmed, keyed by
@@ -841,19 +888,30 @@ fn AlternativeCard(
children=move |e| view! { <p class="alt-encouragement">{e}</p> } children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/> />
</div> </div>
<form on:submit=on_submit> {if is_gateway {
<button let href = alternative.action.clone().unwrap_or_default();
type="submit" view! {
class="alt-submit" <a class="alt-submit" href=href>{button_label.clone()}</a>
disabled=move || { }
(!has_requirements && pending_transitions.get().is_empty()) .into_any()
|| submit.pending().get() } else {
|| transition_batch.pending().get() view! {
} <form on:submit=on_submit>
> <button
{button_label.clone()} type="submit"
</button> class="alt-submit"
</form> disabled=move || {
(!has_requirements && pending_transitions.get().is_empty())
|| submit.pending().get()
|| transition_batch.pending().get()
}
>
{button_label.clone()}
</button>
</form>
}
.into_any()
}}
</div> </div>
} }
})} })}
@@ -1372,6 +1430,33 @@ pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnErro
Ok(state.questions.load().get(&path).cloned()) 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<Vec<(String, String)>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::server::AppState;
let state = expect_context::<AppState>();
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult { pub struct SubmitResult {
pub next: Option<String>, pub next: Option<String>,
+30
View File
@@ -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 { .question-responsible {
text-align: center; text-align: center;
color: var(--ink-dim); color: var(--ink-dim);