crates/monetize-ui-web/src/pages/shell.rs
//! **One estate** — everything under `/e/<estate>/…` (owner 2026-10-02).
//!
//! The left menu is the SAME for every estate: Map, Customers, Money (with
//! its Tax), Offer, Events, Settings ([`crate::estate::SECTIONS`]). gunnar.rs,
//! gunnar.acme.se and test2.gunnar.rs are equal, full estates and get exactly
//! this; the only difference between them is a setting, shown under Settings.
//!
//! The shell takes the estate from the ADDRESS (`api::set_estate`), so no
//! page depends on a cookie choosing an estate behind the reader's back. Every
//! call goes to the ONE monetize, which reads the estate from its fleet and
//! its ledger; no estate is dialled (the `x-monetize-estate` header is LEGACY).
//! Where each old page went:
//!
//! | was | is now |
//! |-----------------------------|------------------------------------------|
//! | Products (`/`) | product lines → Map · spend → Money · terms → Offer |
//! | a product (`/products/:p`) | tenants, sales, silent, friction → Customers · fleet, iron, growth → Map |
//! | the estate board | Map, drawn from `/api/estates/<estate>/map` |
//! | a tenant (`/tenant`) | Customers → `/e/<estate>/customer` |
//! | Activity (`/ledger`) | Money; its refused deliveries → Events |
//! | Tax (`/tax`) | Money → Tax |
//! | All estates (`/estates`) | Overview (`/`) |
//! | Passkeys (`/login`) | You ▾ → Passkeys |
use leptos::prelude::*;
use leptos_router::components::{Outlet, A};
use leptos_router::hooks::{use_params_map, use_query_map};
use crate::estate::{estate_href, map_src, summary_path, SummaryJson, NO_MACHINES, SECTIONS};
use crate::pages::tenants::{Part, Tenants};
use crate::pages::ProductsJson;
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel, Tone};
/// The estate the shell is showing, from the address.
pub fn use_estate() -> Memo<String> {
let params = use_params_map();
Memo::new(move |_| params.read().get("estate").unwrap_or_default())
}
/// **The estate's shell**: its name, the same left menu as every other
/// estate's, and the page.
#[component]
pub fn EstateShell() -> impl IntoView {
let estate = use_estate();
view! {
<div class="estate">
<nav class="sidebar" aria-label="Estate">
<div class="estate-name">
<span class="hint">"estate"</span>
<strong class="mono">{move || estate.get()}</strong>
<ServingBadge estate=estate/>
</div>
<div class="nav-group">
{move || {
let e = estate.get();
SECTIONS.iter().map(|(path, label)| {
let href = estate_href(&e, path);
let sub = (*path == "money").then(|| {
let tax = estate_href(&e, "tax");
view! { <A href=tax attr:class="nav-link sub">"Tax"</A> }
});
view! { <A href=href attr:class="nav-link">{*label}</A> {sub} }
}).collect_view()
}}
</div>
</nav>
<main class="page" id="main">
// Re-rendered when the estate in the address changes, AFTER the
// estate every call names has been set: a page never asks the
// estate it is leaving.
{move || {
crate::api::set_estate(Some(estate.get()));
// ONE live feed for every panel of the estate's pages.
crate::feed::provide_feed();
view! { <Outlet/> }
}}
</main>
</div>
}
}
/// Whether this estate's monetize is answering the console, and the money
/// waiting at a vendor — the shell's one status line.
#[component]
fn ServingBadge(estate: Memo<String>) -> impl IntoView {
let health = LocalResource::new(move || {
let _ = estate.get();
async { crate::api::get::<crate::app::Health>("/api/health").await }
});
view! {
{move || match health.get() {
None => view! { <Badge tone=Tone::Plain label="checking …"/> }.into_any(),
Some(Err(f)) => {
let (tone, label) = crate::fmt::health_failure_badge(f.status, f.summary());
view! { <Badge tone=tone label=label/> }.into_any()
}
Some(Ok(h)) if !h.serving => view! { <span title=h.error.clone()><Badge tone=Tone::Bad label="not responding"/></span> }.into_any(),
Some(Ok(h)) => {
let (tone, label) = crate::fmt::serving_label(&h.products, h.paid_at_vendor_open);
view! { <Badge tone=tone label=label/> }.into_any()
}
}}
}
}
fn summary_of(estate: Memo<String>) -> LocalResource<Result<SummaryJson, crate::api::Fail>> {
LocalResource::new(move || {
let e = estate.get();
async move { crate::api::get::<SummaryJson>(&summary_path(&e)).await }
})
}
/// **MAP** — front · main · twin: the estate's system map as its own monetize
/// draws it (`GET /api/estates/<estate>/map`), the product lines, and per
/// product the fleet, the iron at the provider and the data-set growth.
#[component]
pub fn MapPage() -> impl IntoView {
let estate = use_estate();
let summary = summary_of(estate);
let query = use_query_map();
let only = Memo::new(move |_| query.read().get("product").unwrap_or_default());
let broken = RwSignal::new(false);
view! {
<PageHeader title=move || format!("{} · Map", estate.get()) subtitle="Front, main and twin, and how each is doing now."/>
<div class="stack">
{move || {
let e = estate.get();
let src = map_src(&e);
let alt = format!("Map of {e}");
match summary.get() {
None => view! { <Panel title="Board"><crate::ui::Loading rows=2/></Panel> }.into_any(),
Some(Ok(s)) if s.reached && s.machines == Some(0) => view! {
<Panel title="Board">
<div class="panel-body">
<Empty title=capitalised(NO_MACHINES) detail="The machines show here once the estate is installed."/>
<details class="more">
<summary>"the empty board, as drawn"</summary>
<img class="estate-board" src=src alt=alt/>
</details>
</div>
</Panel>
}.into_any(),
Some(Ok(s)) if !s.reached => view! {
<Panel title="Board">
<div class="panel-body">
<Empty title=if s.unfed { capitalised(crate::estate::UNFED) } else { capitalised(crate::estate::NOT_REACHED) } detail=s.error.clone().unwrap_or_default()/>
</div>
</Panel>
}.into_any(),
_ if broken.get() => view! {
<Panel title="Board">
<div class="panel-body">
<Empty title="The map did not load" detail="Try again in a moment."/>
</div>
</Panel>
}.into_any(),
_ => view! {
<Panel title="Board">
<div class="panel-body">
<img class="estate-board" src=src alt=alt on:error=move |_| broken.set(true)/>
</div>
</Panel>
}.into_any(),
}
}}
{move || match summary.get() {
// MAP-SEG 1: THIS estate's products and customers, never every product's fleet.
Some(Ok(s)) => view! { <crate::pages::products::ProductLines site=(s.reached, s.installed) estate=(s.products.clone(), s.customers)/> }.into_any(),
_ => ().into_any(),
}}
{move || summary.get().and_then(|r| r.ok()).map(|s| view! { <SiteMachines s=s/> })}
{move || summary.get().and_then(|r| r.ok()).map(|s| s.products.into_iter().filter(|p| only.get().is_empty() || *p == only.get()).map(|p| view! {
<h2 class="section-title mono">{p.clone()}</h2>
<Tenants product=p part=Part::Machines estate=estate.get_untracked()/>
}).collect_view())}
</div>
}
}
/// The site's machines, from their own reports (`estate::site_machines`).
#[component]
fn SiteMachines(s: SummaryJson) -> impl IntoView {
let rows = crate::estate::site_machines(&s);
(!rows.is_empty()).then(|| {
view! {
<Panel title="Machines">
<div class="cloud-list">
{rows.into_iter().map(|(role, host, who)| view! {
<div class="cloud-row">
<span class="id mono">{role}</span>
<span class="where hint mono">{host}</span>
<span class="mark hint">{who}</span>
</div>
}).collect_view()}
</div>
</Panel>
}
})
}
fn capitalised(s: &str) -> String {
let mut c = s.chars();
c.next()
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
.unwrap_or_default()
}
/// **CUSTOMERS** — the estate's tenants per product: closest to their cap
/// first, what they bought, who went quiet, who is being refused. One product
/// with `?product=`, else every product the estate serves.
#[component]
pub fn CustomersPage() -> impl IntoView {
let estate = use_estate();
let query = use_query_map();
let only = Memo::new(move |_| query.read().get("product").unwrap_or_default());
let products = LocalResource::new(move || {
let _ = estate.get();
async { crate::api::get::<ProductsJson>("/api/products").await }
});
view! {
<PageHeader title=move || format!("{} · Customers", estate.get())/>
{move || resolved(products.get(), None, move |p: ProductsJson| {
let ids: Vec<String> = p.products.iter().map(|c| c.id.clone()).filter(|id| only.get().is_empty() || *id == only.get()).collect();
if ids.is_empty() {
return view! { <Empty title="No products" detail="This estate sells nothing yet."/> }.into_any();
}
let several = ids.len() > 1;
view! {
<div class="stack">
{ids.into_iter().map(|id| view! {
{several.then(|| view! { <h2 class="section-title mono">{id.clone()}</h2> })}
<Tenants product=id part=Part::Customers estate=estate.get_untracked()/>
}).collect_view()}
</div>
}.into_any()
})}
}
}
/// **OFFER** — what this estate sells: its price list and terms, and the shop
/// window as one customer would see it (bundles are priced on the delta over
/// what THAT customer holds, so the window needs a customer to be asked for).
#[component]
pub fn OfferPage() -> impl IntoView {
let estate = use_estate();
view! {
<PageHeader title=move || format!("{} · Offer", estate.get()) subtitle="Prices, terms, and what a customer is offered."/>
<div class="stack">
<crate::pages::products::Terms/>
<ShopWindow/>
</div>
}
}
/// The bundles one customer would be offered, with monetize's own verdict on
/// each row (`/api/bundles` — a read that reserves nothing).
#[component]
fn ShopWindow() -> impl IntoView {
let products =
LocalResource::new(|| async { crate::api::get::<ProductsJson>("/api/products").await });
let tenant = RwSignal::new(String::new());
let asked = RwSignal::new(None::<(String, String)>);
let window = LocalResource::new(move || {
let q = asked.get();
async move {
match q {
Some((p, t)) => crate::api::get::<crate::model::BundlesJson>(&format!(
"/api/bundles?product={}&tenant={}",
crate::fmt::percent_encode(&p),
crate::fmt::percent_encode(&t)
))
.await
.map(Some),
None => Ok(None),
}
}
});
view! {
<Panel title="Bundles">
<form class="panel-body row" on:submit=move |ev| {
ev.prevent_default();
let p = products.get().and_then(|r| r.ok()).and_then(|p| p.products.first().map(|c| c.id.clone())).unwrap_or_default();
let t = tenant.get_untracked();
if !p.is_empty() && !t.trim().is_empty() {
asked.set(Some((p, t.trim().to_owned())));
}
}>
<label class="field">
<span class="hint">"Customer"</span>
<input class="mono" placeholder="a customer's name" prop:value=move || tenant.get() on:input=move |ev| tenant.set(event_target_value(&ev))/>
</label>
<button class="btn" type="submit">"Show offer"</button>
</form>
{move || match window.get() {
Some(Ok(Some(w))) if w.bundles.is_empty() => view! { <Empty title="No bundles" detail="Nothing to offer yet."/> }.into_any(),
Some(Ok(Some(w))) => view! {
<div class="table-wrap">
<table aria-label="Bundles">
<thead><tr><th scope="col">"Bundle"</th><th scope="col">"Price"</th><th scope="col">"Can serve"</th></tr></thead>
<tbody>
{w.bundles.into_iter().map(|b| view! {
<tr>
<td><span class="mono">{b.id.clone()}</span>" "<span class="hint">{b.label.clone()}</span></td>
<td class="mono">{b.price_words.clone()}</td>
<td>{if b.can_serve { view! { <Badge tone=Tone::Pass label="yes"/> }.into_any() } else { view! { <span title=b.reason.clone()><Badge tone=Tone::Warn label="no"/></span> }.into_any() }}</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
Some(Err(f)) => view! { <div class="panel-body"><crate::ui::FailBox fail=f/></div> }.into_any(),
_ => view! { <p class="panel-body hint">"Enter a customer's name to see what they are offered."</p> }.into_any(),
}}
</Panel>
}
}