crates/monetize-ui-web/src/app.rs
//! The console's shell: the top bar (estate picker · Overview · You), the
//! session, and the routes. Since 2026-10-02 the ESTATE is the top level:
//! `/` is the Overview of every estate, `/e/<estate>/…` is one estate.
use leptos::prelude::*;
use leptos_router::components::{ParentRoute, Route, Router, Routes, A};
use leptos_router::path;
use serde::Deserialize;
use crate::pages;
use crate::ui::{Badge, Tone};
/// `/auth/status`, shared by every page through context.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Status {
pub authenticated: bool,
pub user: String,
pub passkey_enabled: bool,
/// True when nothing guards the console at all.
pub open: bool,
/// True while the break-glass token can still enrol the first admin.
pub bootstrap: bool,
/// True when the signed-in principal already holds an enabled passkey on this console:
/// the passkeys page then offers no enrol form until "Add another key" is pressed.
#[serde(default)]
pub has_passkey: bool,
}
impl Status {
/// There are no roles (owner 2026-11-03): a signed-in operator may do everything
/// the console does (an open loopback console too).
pub fn is_operator(&self) -> bool {
self.open || self.authenticated
}
}
#[derive(Clone, Copy)]
pub struct SessionContext(pub RwSignal<Option<Status>>);
/// `None` until `/auth/status` has answered. Pages must not guess in the meantime.
pub fn session() -> RwSignal<Option<Status>> {
use_context::<SessionContext>()
.expect("the shell provides the session")
.0
}
/// To the login page, carrying why — `fmt::login_prompt` turns the word into
/// the line and the red bulb the page shows.
pub fn go_to_login_because(why: &str) {
if let Some(w) = web_sys::window() {
if w.location().pathname().unwrap_or_default() != "/login" {
let _ = w.location().set_href(&format!("/login?why={why}"));
}
}
}
pub fn go_to_login() {
if let Some(w) = web_sys::window() {
if w.location().pathname().unwrap_or_default() != "/login" {
let _ = w.location().set_href("/login");
}
}
}
#[component]
pub fn App() -> impl IntoView {
let status = RwSignal::new(None::<Status>);
provide_context(SessionContext(status));
Effect::new(move |_| {
leptos::task::spawn_local(async move {
if let Ok(fresh) = crate::api::get::<Status>("/auth/status").await {
if crate::fmt::leaves_for_login(
fresh.authenticated,
fresh.open,
fresh.passkey_enabled,
) {
go_to_login();
}
status.set(Some(fresh));
}
});
});
view! {
<Router>
<div class="app">
// THE TOP BAR (owner 2026-10-02): the estate picker, Overview,
// and You. The ESTATE is the top level; a product is only a
// heading inside the picker.
<header class="topbar">
<a class="brand" href="/" title="monetize — the operator console">
<img src="/monetize.webp" alt="" class="brand-mark"/>
<span>"monetize"</span>
</a>
<EstatePicker/>
<A href="/" attr:class="nav-link top" exact=true>"Overview"</A>
<A href="/accounts" attr:class="nav-link top">"Accounts"</A>
<A href="/guardian" attr:class="nav-link top">"Guardian"</A>
<A href="/test-buy" attr:class="nav-link top">"Test buy"</A>
<A href="/sites" attr:class="nav-link top">"Sites"</A>
<A href="/iron" attr:class="nav-link top">"Iron"</A>
<span class="spacer"></span>
<You/>
</header>
<CodeRedBanner/>
<Routes fallback=|| view! { <main class="page" id="main"><div class="empty"><h3>"Page not found"</h3><p>"This address leads nowhere in this console. "<a href="/">"Overview"</a>" lists every estate."</p></div></main> }>
<Route path=path!("/") view=pages::overview::Overview/>
<Route path=path!("/accounts") view=pages::accounts::Accounts/>
<Route path=path!("/account") view=pages::accounts::AccountDetail/>
<Route path=path!("/guardian") view=pages::guardian::Guardian/>
<Route path=path!("/guardian/estate") view=pages::guardian::GuardianEstate/>
<Route path=path!("/test-buy") view=pages::test_buy::TestBuy/>
<Route path=path!("/sites") view=pages::sites::Sites/>
<Route path=path!("/iron") view=pages::iron::Iron/>
// The old Customers page is replaced by Accounts (5.4).
<Route path=path!("/customers") view=pages::accounts::Accounts/>
<Route path=path!("/login") view=|| view! { <main class="page" id="main"><pages::login::Login/></main> }/>
<Route path=path!("/invite") view=|| view! { <main class="page" id="main"><pages::invite::Invite/></main> }/>
<ParentRoute path=path!("/e/:estate") view=pages::shell::EstateShell>
<Route path=path!("") view=pages::shell::MapPage/>
<Route path=path!("map") view=pages::shell::MapPage/>
<Route path=path!("customers") view=pages::shell::CustomersPage/>
<Route path=path!("customer") view=pages::tenant::TenantDetail/>
<Route path=path!("money") view=pages::ledger::LedgerPage/>
<Route path=path!("tax") view=pages::tax::TaxPage/>
<Route path=path!("offer") view=pages::shell::OfferPage/>
<Route path=path!("events") view=pages::ledger::EventsPage/>
<Route path=path!("settings") view=pages::settings::SettingsPage/>
</ParentRoute>
</Routes>
</div>
</Router>
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Health {
pub serving: bool,
#[serde(default)]
pub products: Vec<String>,
#[serde(default)]
pub error: String,
/// Paid-at-vendor cases a person still has to act on. `None` when
/// monetize could not be asked — which the badge must not read as zero.
#[serde(default)]
pub paid_at_vendor_open: Option<u64>,
}
/// **The estate picker** — `[ gunnar.rs ▾ ]`, every estate this console
/// administers grouped under its product (a heading, nothing more). Picking
/// one opens the SAME section of that estate (Money of gunnar.rs → Money of
/// test2.gunnar.rs), with a full load so every page and the live feed speak
/// to the estate just picked.
#[component]
fn EstatePicker() -> impl IntoView {
let location = leptos_router::hooks::use_location();
let here = Memo::new(move |_| crate::estate::estate_in(&location.pathname.get()));
let estates = LocalResource::new(|| async {
crate::api::get::<crate::model::EstatesJson>("/api/estates").await
});
view! {
<label class="estate-picker">
<span class="vh">"Estate"</span>
<select aria-label="Estate" on:change=move |ev| {
let name = event_target_value(&ev);
if name.is_empty() {
return;
}
let section = here.get_untracked().map(|(_, s)| s).unwrap_or_else(|| "map".to_owned());
// A customer of one estate is not a customer of another.
let section = if section == "customer" { "customers".to_owned() } else { section };
if let Some(w) = web_sys::window() {
let _ = w.location().set_href(&crate::estate::estate_href(&name, §ion));
}
}>
{move || {
let current = here.get().map(|(e, _)| e).unwrap_or_default();
let groups = match estates.get() {
Some(Ok(all)) => crate::estate::picker_groups(&all.estates),
_ => Vec::new(),
};
let known = groups.iter().any(|g| g.estates.contains(¤t));
view! {
<option value="" selected=current.is_empty() disabled=true>{if groups.is_empty() { "estates …" } else { "Pick an estate" }}</option>
// The estate in the address, before the list has answered.
{(!current.is_empty() && !known).then(|| view! { <option value=current.clone() selected=true>{current.clone()}</option> })}
{groups.into_iter().map(|g| {
let current = current.clone();
view! {
<optgroup label=g.heading.clone()>
{g.estates.into_iter().map(|n| {
let selected = n == current;
view! { <option value=n.clone() selected=selected>{n.clone()}</option> }
}).collect_view()}
</optgroup>
}
}).collect_view()}
}
}}
</select>
</label>
}
}
/// **You ▾** — who is logged in, the passkeys, and the way out.
#[component]
fn You() -> impl IntoView {
let status = session();
view! {
<details class="you">
<summary class="nav-link top">"You ▾"</summary>
<div class="you-menu">
{move || match status.get() {
None => view! { <span class="badge plain">"…"</span> }.into_any(),
Some(s) if s.open => view! { <Badge tone=Tone::Warn label="unlocked console"/> }.into_any(),
Some(s) if s.authenticated => view! { <span class="badge plain">{s.user.clone()}</span> }.into_any(),
Some(_) => view! { <span class="hint">"not logged in"</span> }.into_any(),
}}
<a class="nav-link" href="/login">"Passkeys"</a>
{move || match status.get() {
Some(s) if s.authenticated && !s.open => view! {
<button class="btn quiet" on:click=move |_| {
leptos::task::spawn_local(async move {
let _ = crate::api::post::<_, serde_json::Value>("/auth/logout", &serde_json::json!({})).await;
go_to_login();
});
}>"Log out"</button>
}.into_any(),
Some(s) if !s.open => view! { <a class="btn quiet" href="/login">"Log in"</a> }.into_any(),
_ => ().into_any(),
}}
</div>
</details>
}
}
/// **CODE RED, on every page** (owner 2026-10-18): what an automatic path left down — a red
/// blinking banner under the top bar, read every [`crate::code_red::REREAD_MS`] while signed in;
/// an admin's Ack takes one off (monetize keeps it until its path succeeds).
#[component]
fn CodeRedBanner() -> impl IntoView {
let reread = RwSignal::new(0u32);
let reds = RwSignal::new(crate::code_red::CodeRedsJson::default());
let failed = RwSignal::new(None::<String>);
Effect::new(move |_| {
reread.get();
let signed_in = session().get().is_some_and(|s| s.authenticated || s.open);
if signed_in {
leptos::task::spawn_local(async move {
if let Ok(r) =
crate::api::get::<crate::code_red::CodeRedsJson>(crate::code_red::CODE_RED_API)
.await
{
reds.set(r);
}
});
}
set_timeout(
move || reread.update(|n| *n = n.wrapping_add(1)),
std::time::Duration::from_millis(u64::from(crate::code_red::REREAD_MS)),
);
});
let admin = move || session().get().map(|s| s.is_operator()).unwrap_or(false);
view! {
{move || {
let lines = crate::code_red::banner_lines(&reds.get());
(!lines.is_empty()).then(|| view! {
<div class="code-red" role="alert" aria-live="assertive">
{lines.into_iter().map(|(key, line, next)| {
let ack = move |_| {
let key = key.clone();
leptos::task::spawn_local(async move {
match crate::api::post::<_, crate::code_red::CodeRedsJson>(&crate::code_red::ack_path(&key), &serde_json::json!({})).await {
Ok(r) => reds.set(r),
Err(f) => failed.set(Some(f.message)),
}
});
};
view! {
<div class="code-red-line">
<strong class="code-red-what">{line}</strong>
{(!next.is_empty()).then(|| view! { <span class="code-red-next">{format!("Next: {next}")}</span> })}
{admin().then(|| view! { <button class="btn code-red-ack" on:click=ack>"Ack"</button> })}
</div>
}
}).collect_view()}
{move || failed.get().map(|f| view! { <div class="code-red-failed">{f}</div> })}
</div>
})
}}
}
}