crates/monetize-ui-web/src/pages/products.rs
//! What was `docs/v2/07-console.md` screen 1 (the Products page), re-homed (owner 2026-10-02:
//! there is no Products menu any more — an estate's product is what that
//! estate serves, and what it sells lives under the estate's Offer):
//!
//! * [`ProductLines`] — one line per product the estate serves: the dot
//! (reachability), the tenant count, the fleet's `used of capacity` bar or
//! its honest unmeasured state, the rate, and what is owned in the cloud.
//! On the estate's MAP. Every word is decided in `fmt` (`reach_dot`,
//! `primary_meter`, `fleet_figure`, `rate_line`, `cloud_cell`).
//! * [`SpendLines`] — what has been spent at each cloud against its ceiling,
//! as a LIFETIME total, one line per cloud named by provider, the cloud a
//! purchase provisions on first marked (`spend_lines`). On the estate's
//! MONEY. Absent on a box without a cloud; `no ceiling` in words on an
//! unbounded one.
//! * [`Terms`] — what a GiB costs the customer per product and market, or
//! `free`, and the period, grace and retention. On the estate's OFFER. The
//! price list is `monetize.toml`'s and is not edited here.
//!
//! Each asks `/api/products` of the estate the shell is showing.
use leptos::prelude::*;
use crate::fmt::{
cloud_cell, fleet_figure, money, primary_meter, rate_line, reach_dot, spend_lines,
};
use crate::pages::{product_href, ProductsJson};
use crate::ui::{resolved, Dot, Empty, Panel, Track};
fn products() -> LocalResource<Result<ProductsJson, crate::api::Fail>> {
LocalResource::new(|| async { crate::api::get::<ProductsJson>("/api/products").await })
}
/// One line per product this estate serves — on its Map.
#[component]
pub fn ProductLines(
/// The site's summary (`reached`, `installed`): its fleet reports decide
/// the dot ([`crate::fmt::site_reach`]). None: the product's own word.
#[prop(optional)]
site: Option<(bool, Option<bool>)>,
/// **ONE estate's lines** (MAP-SEG 1): the products it serves and its customers, from
/// its summary ([`crate::fmt::estate_product_lines`]). Its iron is its Map's Iron panel,
/// so no account-wide cloud cell. None: every product, the product's own counts.
#[prop(optional)]
estate: Option<(Vec<String>, Option<u64>)>,
) -> impl IntoView {
let p = products();
view! {
{move || resolved(p.get(), None, {
let estate = estate.clone();
move |p: ProductsJson| {
let lines: Vec<(crate::model::ProductCard, String, bool)> = match &estate {
Some((serves, customers)) => crate::fmt::estate_product_lines(&p.products, serves, *customers).into_iter().map(|(c, t)| (c.clone(), t, false)).collect(),
None => p.products.iter().map(|c| (c.clone(), format!("{} tenant{}", c.tenants, if c.tenants == 1 { "" } else { "s" }), true)).collect(),
};
if lines.is_empty() {
return view! { <Empty title="No products" detail="This estate has no product yet."/> }.into_any();
}
view! {
<section class="product-list" aria-label="Products">
{lines.into_iter().map(|(card, tenants, account)| {
let (tone, glyph, word) = reach_dot(crate::fmt::site_reach(&card.reachable.state, site.map(|s| s.0), site.and_then(|s| s.1)));
let meter = primary_meter(card.trend.as_ref(), &card.meters).map(str::to_owned);
let figure = fleet_figure(&card.fleet, meter.as_deref());
let rate = rate_line(card.trend.as_ref());
let cloud = if account { cloud_cell(card.fleet.cloud.as_ref()) } else { None };
let title = if card.reachable.detail.is_empty() { word.to_owned() } else { format!("{word}: {}", card.reachable.detail) };
let id = card.id.clone();
view! {
<a class="product-row" href=product_href(&card.id)>
<Dot tone=tone glyph=glyph title=title/>
<span class="id mono">{id}</span>
{if word.is_empty() {
view! {
<span class="tenants">{tenants}</span>
<span class="figure mono" title=figure.why().to_owned()>{figure.text()}</span>
<span class="bar">{figure.bar().map(|(state, width)| view! { <Track state=state width=width label=card.id.clone()/> })}</span>
<span class="rate mono">{rate.unwrap_or_default()}</span>
<span class="cloud mono">{cloud.unwrap_or_default()}</span>
}.into_any()
} else {
view! { <span class="tenants hint">{word}</span> }.into_any()
}}
</a>
}
}).collect_view()}
</section>
}.into_any()
}})}
}
}
/// The spend against each cloud's ceiling — on the estate's Money.
#[component]
pub fn SpendLines() -> impl IntoView {
let p = products();
view! {
{move || resolved(p.get(), None, |p: ProductsJson| {
let spend = spend_lines(&p.spend, &p.default_cloud);
if spend.is_empty() {
return ().into_any();
}
view! {
<Panel title="Spent at the clouds">
<div class="panel-body stack">
{spend.into_iter().map(|line| {
let label = format!("Spend against the {} ceiling", line.provider);
let bar_label = label.clone();
view! {
<div class=if line.unbounded { "spend-line unbounded" } else { "spend-line" } aria-label=label>
<span class="id mono">{line.provider.clone()}</span>
<span class="hint" title=if line.default { "new purchases use this cloud" } else { "" }>{if line.default { "default · purchases go here" } else { "" }}</span>
<span class="mono">{line.text}</span>
<span class="bar">{line.bar.map(|(state, width)| view! { <Track state=state width=width label=bar_label.clone()/> })}</span>
</div>
}
}).collect_view()}
</div>
</Panel>
}.into_any()
})}
}
}
/// **The terms** — what this estate sells, per product and market — on its Offer.
#[component]
pub fn Terms() -> impl IntoView {
let p = products();
view! {
{move || resolved(p.get(), None, |p: ProductsJson| {
let period = if p.policy.period.is_empty() { "period".to_owned() } else { p.policy.period.clone() };
let rows: Vec<(String, String, String)> = p
.products
.iter()
.flat_map(|card| {
let lists: Vec<(String, String, String)> = p
.prices
.iter()
.filter(|l| l.product == card.id)
.map(|l| {
let disk = l.units.get("disk_gib_month").copied().unwrap_or(0);
let price = if disk == 0 { "free".to_owned() } else { format!("{} / GiB / {period}", money(disk, &l.currency)) };
(card.id.clone(), l.market.clone(), price)
})
.collect();
if lists.is_empty() { vec![(card.id.clone(), "every country".to_owned(), "free".to_owned())] } else { lists }
})
.collect();
let policy = format!(
"billed per {period} · {} days of grace · kept {} days after that",
p.policy.grace_days, p.policy.retention_days
);
view! {
<Panel title="Prices">
<div class="table-wrap">
<table aria-label="Prices">
<thead><tr><th scope="col">"Product"</th><th scope="col">"Country"</th><th scope="col">"Disk"</th></tr></thead>
<tbody>
{rows.into_iter().map(|(product, market, price)| view! {
<tr><td class="mono">{product}</td><td class="mono">{market}</td><td class="mono">{price}</td></tr>
}).collect_view()}
</tbody>
</table>
</div>
<p class="panel-body hint" title="a price of zero is free">{policy}</p>
</Panel>
}.into_any()
})}
}
}