Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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>
    }
}