crates/monetize-ui-web/src/pages/tenant.rs
//! `docs/v2/07-console.md` screen 3 — a tenant.
//!
//! One number is big: the tenant's standing on the product's headline meter,
//! with `AT CAP` when a wall is being refused on. One trend line. The one
//! action, `[ How much? ]`, opens the order form ([`CapacityOrder`]): the caps
//! the tenant is to be ALLOWED, quoted BEFORE money moves — the price from the
//! deployment's list (`free` when there is none), the cost the provider would
//! charge, and whether it can be served with the refusal in the product's own
//! words — then confirmed. USING lists the per-tenant meters in plain words
//! (`fmt::plain_meter`; the wire name is in the title) with `wall` / `billed` /
//! `not billed` / `still costs` beside each, read off the price list
//! (`fmt::billing_word`). BACKED BY is what was BOUGHT, distinct from what is
//! allowed. `History →` opens the Activity screen scoped to this tenant. The
//! entitlement fact and the operator's hand-set door live behind a disclosure —
//! they are the detail, not the page.
//!
//! **ACTIVITY, under USING, is the other kind of number.** Every bar in USING
//! is a STOCK reading — how much is on the disk — and it reads the same for a
//! tenant who pushed this morning and one who stopped in March. The Activity
//! panel is what MOVED: the last thirty days as four strips of daily bars
//! (`fmt::activity_series`), the days the product said no marked in red, and
//! one line saying when this tenant was last seen at all. The flow meters are
//! therefore NOT bars in USING (`fmt::is_flow_meter`); a Unix millisecond
//! drawn as a quantity a tenant is using is a bar making a claim that is not
//! true.
use std::collections::BTreeMap;
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use serde_json::{json, Value};
use crate::api::Fail;
use crate::fmt::{
activity_empty_line, activity_series, billing_word, can_serve_words, caps_query, cost_words,
date, form_value, holding_line, is_flow_meter, last_seen_line, list_for, meter_value,
month_day, on_tenant_screen, order_caps, parse_date_ms, price_words, primary_meter, since,
standing, standing_of, trend_line, unit_price,
};
use crate::pages::{
activity_href, now_ms, product_href, ActivityJson, BundleJson, BundlesJson, QuoteJson,
TenantDetailJson,
};
use crate::ui::{
resolved, Badge, BarRow, Bars, Empty, FailBox, PageHeader, Panel, StateBadge, Tone, Track,
};
#[component]
pub fn TenantDetail() -> impl IntoView {
let query = use_query_map();
let product = Memo::new(move |_| query.read().get("product").unwrap_or_default());
let tenant = Memo::new(move |_| query.read().get("tenant").unwrap_or_default());
let feed = crate::feed::use_feed();
let reload = RwSignal::new(0u32);
let data = LocalResource::new(move || {
reload.get();
feed.reread.get();
let (p, t) = (product.get(), tenant.get());
async move {
crate::api::get::<TenantDetailJson>(&format!(
"/api/tenants/{}/{}",
crate::fmt::percent_encode(&p),
crate::fmt::percent_encode(&t)
))
.await
}
});
let retry = Callback::new(move |()| reload.update(|n| *n += 1));
// **THE ACTIVITY READ IS ITS OWN, and it is not the `activity_href` in
// `pages/mod.rs`** — that one is the SALES screen (`/ledger`), a different
// meaning of the word, and neither is renamed here. This is a data route
// with no page behind it, so it needs no href function at all; the path is
// built where it is asked for.
//
// Separate from the tenant read for one reason: `/activity` calls the
// PRODUCT and the tenant read does not, so a slow or unreachable box would
// otherwise hold up the whole page — the bars, the quota, the order form.
// It is deliberately NOT tied to `feed.reread` either: a day series changes
// by the day, and re-asking the product on every reading that lands is a
// poll dressed as a page.
let activity_reload = RwSignal::new(0u32);
let activity = LocalResource::new(move || {
activity_reload.get();
let (p, t) = (product.get(), tenant.get());
async move {
crate::api::get::<ActivityJson>(&format!(
"/api/tenants/{}/{}/activity",
crate::fmt::percent_encode(&p),
crate::fmt::percent_encode(&t)
))
.await
}
});
let activity_retry = Callback::new(move |()| activity_reload.update(|n| *n += 1));
let back = move || product_href(&product.get());
let back_label = move || format!("← {}", product.get());
// THE ORDER FORM LIVES OUTSIDE THE RELOADING VIEW. The page re-reads on
// every feed tick, and a form created inside `resolved(...)` was rebuilt
// with it — an operator typing a number, or reading a quote, lost it the
// moment a reading arrived. MEASURED in the browser matrix: the panel
// opened, and was gone before the quote came back. So the open/closed
// state and the detail the form was opened on are held here, and the
// form is created once, when it opens.
let ordering = RwSignal::new(false);
let latest = RwSignal::new(None::<TenantDetailJson>);
Effect::new(move |_| {
if let Some(Ok(d)) = data.get() {
latest.set(Some(d));
}
});
view! {
<PageHeader title=move || tenant.get()>
<a class="btn quiet" href=back>{back_label}</a>
</PageHeader>
<Show when=move || ordering.get()>
{move || latest.get_untracked().map(|d| view! {
<QuickBuy detail=d.clone() on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
<CapacityOrder detail=d on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
})}
</Show>
{move || resolved(data.get(), Some(retry), move |d: TenantDetailJson| {
let now = now_ms();
let caps = d.fact.as_ref().map(|f| f.caps.clone()).unwrap_or_default();
let meter = primary_meter(d.trend.as_ref(), &d.meters).map(str::to_owned);
let enforcement_of = |name: &str| d.meters.iter().find(|m| m.name == name).map(|m| m.enforcement.clone()).unwrap_or_default();
// The big number: this tenant's standing on the headline meter.
let head = match (&d.row, &meter) {
(Some(row), Some(m)) => Some(standing(row, m, &enforcement_of(m))),
_ => None,
};
let trend = head.as_ref().and_then(|s| s.used.map(|u| (u, s.cap))).and_then(|(u, cap)| trend_line(d.trend.as_ref(), u, cap));
// The USING words are read off THIS product's list — the market's
// when several, the first otherwise, none when free.
let list = list_for(&d.prices, &d.product, None).cloned();
// A flow meter is not a bar: it belongs to the Activity panel
// below, where a day is a day and a timestamp is a date.
let bars: Vec<BarRow> = d.meters.iter().filter(|m| on_tenant_screen(&m.scope) && !is_flow_meter(&m.name)).map(|m| BarRow {
name: m.name.clone(),
meaning: m.meaning.clone(),
standing: standing_of(&m.name, d.row.as_ref().and_then(|r| r.meters.get(&m.name).copied()), caps.get(&m.name).copied(), &m.enforcement),
billing: billing_word(&m.name, &m.enforcement, unit_price(m, list.as_ref())),
}).collect();
let measured = d.row.as_ref().and_then(|r| r.measured_at_unix_ms);
// WHEN THIS TENANT WAS LAST SEEN AT ALL — off the reading the page
// already has, not off the activity call, so a product that will
// not answer the day series still cannot erase the one line that
// says whether anybody is there. A product that reports NEITHER
// meter gets no line: `never seen` about a product that does not
// count flow would be a claim about a customer made from a gap in
// a plugin.
let last_seen = d.row.as_ref().filter(|r| r.meters.contains_key("last_push_unix_ms") || r.meters.contains_key("last_fetch_unix_ms")).map(|r| {
let at = |m: &str| r.meters.get(m).copied().unwrap_or(0);
last_seen_line(at("last_push_unix_ms"), at("last_fetch_unix_ms"), now)
});
let fact = d.fact.clone();
let pending_push = d.pending_push.clone();
let holdings = d.holdings.clone();
let history = activity_href(&d.product, Some(&d.tenant));
let tenant_id = d.tenant.clone();
let for_operator = d.clone();
view! {
<div class="stack">
<section class="big" aria-label="This tenant">
{match &head {
Some(s) => view! {
<div class="number mono">{s.text()}</div>
<div class="big-bar"><Track state=s.state width=s.width label=tenant_id.clone()/></div>
<p class=if s.state == crate::fmt::BarState::AtLimit { "trend mono at-cap" } else { "trend mono" }>
{if s.state == crate::fmt::BarState::AtLimit { format!("{} · ", s.word()) } else { String::new() }}
{trend.clone().unwrap_or_default()}
</p>
}.into_any(),
None => view! { <div class="number hint">"no wall to measure against"</div> }.into_any(),
}}
<Show when=move || crate::app::session().get().is_some_and(|s| s.is_operator())>
<button class="btn primary" on:click=move |_| ordering.update(|s| *s = !*s)>"How much?"</button>
</Show>
</section>
<Panel title="Using" actions=Box::new(move || view! {
<span class="hint">{match measured {
Some(at) => format!("measured {}", since(at, now)),
None => "not measured yet".to_owned(),
}}</span>
}.into_any())>
<div class="panel-body"><Bars meters=bars/></div>
</Panel>
<Panel title="Activity" actions=Box::new(move || match last_seen.clone() {
Some(line) => view! { <span class="hint" title="the later of this tenant's last push and last fetch, over the product's whole kept window">{line}</span> }.into_any(),
None => view! { <span class="hint">"this product does not report flow"</span> }.into_any(),
})>
{move || resolved(activity.get(), Some(activity_retry), |a: ActivityJson| {
if a.days.is_empty() {
// NOT a blank strip: the two silences read the
// same without these words (`fmt` decides which).
return view! { <Empty title="Nothing moved" detail=activity_empty_line(a.days_asked, a.retained_days)/> }.into_any();
}
let asked = a.days_asked;
// The x axis is the window's two ends; every bar
// carries its own day in its title. One date
// formatter (`fmt::month_day`) for all three.
let from = a.days.first().map(|d| month_day(d.day_unix_ms)).unwrap_or_default();
let to = a.days.last().map(|d| month_day(d.day_unix_ms)).unwrap_or_default();
// The key is drawn only when there is something to
// key: a red legend over a month with no refusals
// is a warning about nothing, and an operator who
// sees it every time stops reading it.
let any_refused = a.days.iter().any(|d| d.meters.get(crate::fmt::REFUSED_METER).is_some_and(|n| *n > 0));
view! {
<div class="panel-body">
<div class="days">
{activity_series(&a.days).into_iter().map(|s| {
let total = s.total_text();
let label = format!("{}: {total} over the last {asked} days", s.word);
view! {
<span class="name" title=s.meter>{s.word}</span>
<span class="strip" role="img" aria-label=label>
{s.bars.into_iter().map(|b| view! {
<span class=if b.refused { "day refused" } else { "day" } title=b.title>
// A day that moved nothing draws no bar element at
// all; the cell stays, so its refusal mark and its
// title still say what happened on it.
{(b.width > 0).then(|| view! { <i style=format!("height:{}%", b.width)></i> })}
</span>
}).collect_view()}
</span>
<span class="value">{total}</span>
}
}).collect_view()}
</div>
<p class="axis hint">
<span>{from}</span>
{any_refused.then(|| view! { <span class="refused-key" title="a day the product refused a transfer; the count is in that day's title">"■ refused"</span> })}
<span>{to}</span>
</p>
</div>
}.into_any()
})}
</Panel>
<Panel title="Backed by">
{if holdings.is_empty() {
view! { <Empty title="Nothing bought" detail="Nothing bought at the cloud for this customer."/> }.into_any()
} else {
view! {
<div class="holding-list">
{holdings.into_iter().map(|h| {
let line = holding_line(&h);
let (tone, why) = if h.provisioned {
(Tone::Pass, "monetize bought this and is paying for it; the nonpay purge gives it back")
} else {
(Tone::Plain, "the pool already covered this: real capacity monetize does not own and must not return")
};
view! {
<details>
<summary><span class="mono">{line}</span><span title=why><Badge tone=tone label=if h.provisioned { "bought" } else { "on hand" }/></span></summary>
<dl class="facts">
<dt>"Order"</dt><dd>{h.reference}</dd>
<dt>"Provider id"</dt><dd>{if h.id.is_empty() { "— (on hand)".to_owned() } else { h.id }}</dd>
</dl>
</details>
}
}).collect_view()}
</div>
}.into_any()
}}
</Panel>
<a class="btn quiet" href=history>"History →"</a>
<details class="holder entitlement">
<summary>
"Entitlement"
{match &fact {
Some(f) => view! { " " <StateBadge state=f.state.clone()/> " " <span class="mono hint">{if f.order.is_empty() { "hand-set".to_owned() } else { f.order.clone() }}</span> }.into_any(),
None => view! { " " <Badge tone=Tone::Plain label="no fact: the product treats this tenant as Free"/> }.into_any(),
}}
{pending_push.as_ref().map(|_| view! { " " <Badge tone=Tone::Warn label="not yet acknowledged by the product — retried every poll"/> })}
</summary>
{pending_push.map(|p| view! {
<p class="hint">{format!(
"{} failed push(es) since {}. The product said: {}",
p.attempts,
date(p.since_unix_ms),
p.last_error
)}</p>
})}
{fact.map(|f| view! {
<dl class="facts">
<dt>"State"</dt><dd><StateBadge state=f.state.clone()/></dd>
<dt>"Order"</dt><dd>{if f.order.is_empty() { "— (hand-set)".to_owned() } else { f.order.clone() }}</dd>
<dt>"Paid until"</dt><dd>{f.paid_until_unix_ms.map(date).unwrap_or_else(|| "—".into())}</dd>
<dt>"Source"</dt><dd>{f.source.clone()}</dd>
// Plain words on the surface, the wire name in the title (`docs/v2/07-console.md` rule 3) — even behind a disclosure.
<dt>"Caps"</dt><dd>{if f.caps.is_empty() { view! { "product defaults" }.into_any() } else { f.caps.iter().map(|(k, v)| view! { <span title=k.clone()>{crate::fmt::plain_meter(k)}" = "{meter_value(k, *v)}</span>" " }).collect_view().into_any() }}</dd>
// The console CHECKED this: the BFF fetched monetize's
// verifying key and verified the Ed25519 signature.
<dt>"Signature"</dt><dd>{if f.signed {
view! { <Badge tone=Tone::Pass label="verified against monetize's key"/> }.into_any()
} else {
view! { <Badge tone=Tone::Bad label="does not verify"/> }.into_any()
}}</dd>
</dl>
})}
<Show when=move || crate::app::session().get().is_some_and(|s| s.is_operator())>
<OperatorAction detail=for_operator.clone() on_done=Callback::new(move |()| reload.update(|n| *n += 1))/>
</Show>
</details>
</div>
}
})}
}
}
/// Set state by hand, two steps: arm, then confirm.
#[component]
fn OperatorAction(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
let current = detail.fact.clone();
let label = RwSignal::new(String::new());
let state = RwSignal::new(
current
.as_ref()
.map(|f| f.state.clone())
.unwrap_or_else(|| "free".into()),
);
let paid_until = RwSignal::new(
current
.as_ref()
.and_then(|f| f.paid_until_unix_ms)
.map(date)
.unwrap_or_default(),
);
let armed = RwSignal::new(false);
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<Fail>);
let done = RwSignal::new(None::<String>);
let (product, tenant) = (detail.product.clone(), detail.tenant.clone());
// A `Callback` is `Copy`, so the reactive arm/confirm block below can hold it.
let submit = Callback::new(move |()| {
if busy.get_untracked() {
return;
}
let until = paid_until.get_untracked();
let paid_until_ms = if until.trim().is_empty() {
None
} else {
match parse_date_ms(&until) {
Some(ms) => Some(ms),
None => {
failed.set(Some(Fail {
status: 0,
code: String::new(),
message: format!("{until:?} is not a YYYY-MM-DD date"),
}));
return;
}
}
};
busy.set(true);
failed.set(None);
let body = json!({ "label": label.get_untracked().trim(), "state": state.get_untracked(), "paid_until_unix_ms": paid_until_ms });
let path = format!(
"/api/entitlement/{}/{}",
crate::fmt::percent_encode(&product),
crate::fmt::percent_encode(&tenant)
);
leptos::task::spawn_local(async move {
match crate::api::post::<_, Value>(&path, &body).await {
Ok(r) => {
let pushed = r["pushed"].as_bool().unwrap_or(false);
done.set(Some(if pushed {
"Saved.".into()
} else {
format!(
"Saved. It reaches the product shortly ({}).",
r["push_error"].as_str().unwrap_or("")
)
}));
armed.set(false);
on_done.run(());
}
Err(f) => failed.set(Some(f)),
}
busy.set(false);
});
});
view! {
<Panel title="Operator action">
<div class="panel-body stack">
<p class="hint">"Gives room without payment: for trials, refunds and support."</p>
<div class="row" style="align-items:flex-end">
<div class="field"><label for="label">"Label (optional)"</label>
<input id="label" type="text" placeholder="trial until October" prop:value=move || label.get() on:input=move |ev| label.set(event_target_value(&ev))/>
</div>
<div class="field"><label for="state">"State"</label>
<select id="state" on:change=move |ev| state.set(event_target_value(&ev))>
{["free", "paid", "grace", "suspended", "retention"].into_iter().map(|s| view! { <option value=s selected=move || state.get() == s>{crate::fmt::state_label(s)}</option> }).collect_view()}
</select>
</div>
<div class="field"><label for="until">"Paid until (UTC)"</label>
<input id="until" type="date" prop:value=move || paid_until.get() on:input=move |ev| paid_until.set(event_target_value(&ev))/>
</div>
{move || if armed.get() {
view! {
<button class="btn danger" disabled=move || busy.get() on:click=move |_| submit.run(())>"Confirm: set entitlement"</button>
<button class="btn quiet" on:click=move |_| armed.set(false)>"Cancel"</button>
}.into_any()
} else {
view! { <button class="btn primary" on:click=move |_| { done.set(None); armed.set(true) }>"Set entitlement…"</button> }.into_any()
}}
</div>
{move || done.get().map(|m| view! { <div class="notice">{m}</div> })}
{move || failed.get().map(|f| view! { <FailBox fail=f/> })}
</div>
</Panel>
}
}
// **How much?** — an ORDER: the caps the tenant is to be allowed, quoted,
// then confirmed.
//
// It is deliberately NOT merged with `Entitlement.Set`. That writes a verdict
// and bypasses payment; this reserves money, buys the delta's iron and
// captures — so it can leave a PENDING attempt waiting on a customer, which a
// hand-set fact never does. Sharing a form there would blur exactly the
// distinction the ledger exists to keep.
//
// **The quote comes BEFORE the confirm button, and nothing moves for it.**
// `GET /api/quote` answers three things an operator must see before money
// moves: the PRICE (the deployment's list applied to the delta over what the
// tenant holds — `free` when there is no list, which is the open-source
// deployment), the COST (what the provider would charge us for the iron —
// `this box` on the server's own disk), and CAN SERVE (the product's own
/// **THE BUY BUTTONS.** One per offer this deployment declares, ordered on the
/// press, with no numbers to type and no quote step in front.
///
/// [`CapacityOrder`] beside it is the form that asks WHAT and HOW MUCH and
/// prices it before confirming. That is the right shape for an unusual order
/// and the wrong one for the ordinary case, which is "give this tenant the
/// next 50 GiB" — an operator asked for a button and got two boxes wanting a
/// meter name and a byte count.
///
/// Nothing here computes: `/api/bundles` already carries the label, the price
/// **in monetize's own words** (never minor units re-rendered here), whether
/// the product can serve it, and its reason when it cannot. The press posts
/// the bundle's ID to `/api/purchase`, and `Purchase.Start` adds the bundle's
/// `cap_delta` to what the tenant holds (`caps_of_bundle`). The arithmetic
/// lives once, in monetize.
///
/// **Whatever is declared appears.** Today `[products.gunnar.bundles]` names
/// only `pack_bytes` offers, so the buttons are disk; a deployment that
/// declares CPU or RAM meters and prices them gets those buttons with no
/// change here. A bundle the product cannot serve is shown with its reason and
/// its button disabled, rather than hidden — an operator asking "where is the
/// 100 GiB button" deserves the answer.
#[component]
fn QuickBuy(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
let (product, tenant) = (detail.product.clone(), detail.tenant.clone());
let market = detail
.prices
.iter()
.find(|l| l.product == detail.product)
.map(|l| l.market.clone())
.unwrap_or_default();
let bundles = RwSignal::new(None::<Vec<BundleJson>>);
let busy = RwSignal::new(String::new());
let failed = RwSignal::new(None::<Fail>);
let started = RwSignal::new(None::<Value>);
{
let path = format!(
"/api/bundles?product={}&tenant={}&market={}",
crate::fmt::percent_encode(&product),
crate::fmt::percent_encode(&tenant),
crate::fmt::percent_encode(&market)
);
leptos::task::spawn_local(async move {
match crate::api::get::<BundlesJson>(&path).await {
Ok(b) => bundles.set(Some(b.bundles)),
Err(f) => {
bundles.set(Some(Vec::new()));
failed.set(Some(f));
}
}
});
}
let buy = {
let (product, tenant, market) = (product.clone(), tenant.clone(), market.clone());
Callback::new(move |id: String| {
if !busy.get_untracked().is_empty() {
return;
}
busy.set(id.clone());
failed.set(None);
let mut body =
json!({ "product": product.clone(), "tenant": tenant.clone(), "bundle": id });
if !market.is_empty() {
body["market"] = json!(market.clone());
}
leptos::task::spawn_local(async move {
match crate::api::post::<_, Value>("/api/purchase", &body).await {
Ok(r) => {
started.set(Some(r));
on_done.run(());
}
Err(f) => failed.set(Some(f)),
}
busy.set(String::new());
});
})
};
view! {
<Panel title="Buy">
<div class="panel-body stack">
<p class="hint">"One click buys it. It is added to what this customer has."</p>
{move || match bundles.get() {
None => view! { <p class="hint">"asking what is for sale…"</p> }.into_any(),
Some(rows) if rows.is_empty() => view! {
<Empty title="Nothing is for sale" detail="Nothing for sale for this product."/>
}.into_any(),
Some(rows) => view! {
<div class="row wrap">
{rows.into_iter().map(|b| {
let (id, label, price, serve, why) = (b.id.clone(), b.label.clone(), b.price_words.clone(), b.can_serve, b.reason.clone());
let pressed = id.clone();
view! {
<div class="stack">
<button
class="btn primary"
disabled=move || !serve || !busy.get().is_empty()
on:click=move |_| buy.run(pressed.clone())
>
{move || if busy.get() == id { "buying…".to_owned() } else { format!("{label} · {price}") }}
</button>
{(!serve).then(|| view! { <span class="hint">{why.clone()}</span> })}
</div>
}
}).collect_view()}
</div>
}.into_any(),
}}
{move || failed.get().map(|f| view! { <FailBox fail=f/> })}
{move || started.get().map(|r| view! { <Outcome result=r/> })}
</div>
</Panel>
}
}
/// `can_absorb`, the cloud's answer and the box's ceilings). A refusal is
/// rendered whole: `FailBox` and the can-serve line carry monetize's sentence
/// verbatim, because for gunnar that sentence says what is missing and why —
/// on a sealed appliance it ends by saying a bought disk would be billed and
/// invisible — and the operator's next move is a conversation about a
/// reinstall, not a hunt for a typo.
///
/// The form lists the tenant-scope meters that have a price-list UNIT (the
/// ones an order can buy — disk always; CPU and RAM only where the product
/// meters them per tenant), pre-filled with what the tenant holds, in GiB for
/// bytes. Only a changed meter is sent; the server carries the rest forward.
#[component]
fn CapacityOrder(detail: TenantDetailJson, on_done: Callback<()>) -> impl IntoView {
let vendors = detail.vendors.clone();
let current_caps: BTreeMap<String, u64> = detail
.fact
.as_ref()
.map(|f| f.caps.clone())
.unwrap_or_default();
// What can be ordered: a meter with a unit, on the tenant screen.
let orderable: Vec<(String, String, String)> = detail
.meters
.iter()
.filter(|m| !m.unit.is_empty() && on_tenant_screen(&m.scope))
.map(|m| {
(
m.name.clone(),
crate::fmt::plain_meter(&m.name),
if m.name.ends_with("_bytes") {
"GiB".to_owned()
} else {
String::new()
},
)
})
.collect();
let markets: Vec<String> = detail
.prices
.iter()
.filter(|l| l.product == detail.product)
.map(|l| l.market.clone())
.collect();
let typed = RwSignal::new(
orderable
.iter()
.map(|(name, _, _)| {
(
name.clone(),
form_value(name, current_caps.get(name).copied().unwrap_or(0)),
)
})
.collect::<BTreeMap<String, u64>>(),
);
let market = RwSignal::new(markets.first().cloned().unwrap_or_default());
let vendor = RwSignal::new(vendors.first().cloned().unwrap_or_default());
let payer = RwSignal::new(String::new());
let quote = RwSignal::new(None::<QuoteJson>);
let quoting = RwSignal::new(false);
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<Fail>);
let started = RwSignal::new(None::<Value>);
let (product, tenant) = (detail.product.clone(), detail.tenant.clone());
let period = detail.policy.period.clone();
let nothing_to_order = orderable.is_empty();
// THE QUOTE. A read; nothing is reserved. Every change to the numbers
// invalidates the last one, so a confirm button never sits under a stale price.
let ask = {
let (product, tenant) = (product.clone(), tenant.clone());
let current = current_caps.clone();
Callback::new(move |()| {
if quoting.get_untracked() {
return;
}
let caps = order_caps(¤t, &typed.get_untracked());
if caps.is_empty() {
failed.set(Some(Fail {
status: 0,
code: String::new(),
message: "nothing changed: the tenant already has exactly this".into(),
}));
return;
}
quoting.set(true);
failed.set(None);
quote.set(None);
let path = format!(
"/api/quote?product={}&tenant={}&caps={}&market={}",
crate::fmt::percent_encode(&product),
crate::fmt::percent_encode(&tenant),
crate::fmt::percent_encode(&caps_query(&caps)),
crate::fmt::percent_encode(&market.get_untracked())
);
leptos::task::spawn_local(async move {
match crate::api::get::<QuoteJson>(&path).await {
Ok(q) => quote.set(Some(q)),
Err(f) => failed.set(Some(f)),
}
quoting.set(false);
});
})
};
// THE ORDER: the same caps the quote priced.
let submit = {
let (product, tenant) = (product.clone(), tenant.clone());
let current = current_caps.clone();
Callback::new(move |()| {
if busy.get_untracked() {
return;
}
let caps = order_caps(¤t, &typed.get_untracked());
busy.set(true);
failed.set(None);
let mut body =
json!({ "product": product.clone(), "tenant": tenant.clone(), "caps": caps });
let m = market.get_untracked();
if !m.is_empty() {
body["market"] = json!(m);
}
// Omitted rather than sent empty, so monetize's own defaults apply:
// the only vendor serving the market, the server's clock.
let v = vendor.get_untracked();
if !v.is_empty() {
body["vendor"] = json!(v);
}
let p = payer.get_untracked();
if !p.trim().is_empty() {
body["payer"] = json!(p.trim());
}
leptos::task::spawn_local(async move {
match crate::api::post::<_, Value>("/api/purchase", &body).await {
Ok(r) => {
started.set(Some(r));
quote.set(None);
on_done.run(());
}
Err(f) => failed.set(Some(f)),
}
busy.set(false);
});
})
};
view! {
<Panel title="How much?">
<div class="panel-body stack">
<p class="hint">"How much this customer may use. Only the increase is charged."</p>
{if nothing_to_order {
view! { <Empty title="Nothing to order" detail="Nothing to order for this product."/> }.into_any()
} else {
let fields = orderable.clone();
let mkts = markets.clone();
let vends = vendors.clone();
view! {
<div class="row" style="align-items:flex-end">
{fields.into_iter().map(|(name, plain, unit)| {
let id = format!("order-{name}");
let for_id = id.clone();
let key = name.clone();
let key2 = name.clone();
let label = if unit.is_empty() { plain.clone() } else { format!("{plain} ({unit})") };
view! {
<div class="field"><label for=for_id title=name.clone()>{label}</label>
<input id=id type="number" min="0" step="1"
prop:value=move || typed.get().get(&key).copied().unwrap_or(0).to_string()
on:input=move |ev| { let v = event_target_value(&ev).trim().parse::<u64>().unwrap_or(0); typed.update(|t| { t.insert(key2.clone(), v); }); quote.set(None); }/>
</div>
}
}).collect_view()}
{(mkts.len() > 1).then(|| view! {
<div class="field"><label for="order-market">"Country"</label>
<select id="order-market" on:change=move |ev| { market.set(event_target_value(&ev)); quote.set(None); }>
{mkts.into_iter().map(|m| { let (v, sel) = (m.clone(), m.clone()); view! { <option value=v selected=move || market.get() == sel>{m}</option> } }).collect_view()}
</select>
</div>
})}
<button class="btn" disabled=move || quoting.get() on:click=move |_| ask.run(())>{move || if quoting.get() { "Quoting…" } else { "Quote" }}</button>
</div>
{move || quote.get().map(|q| {
let (tone, serve) = can_serve_words(&q);
let buys_iron = !q.resources.is_empty();
let delta = crate::fmt::caps_phrase(&q.delta);
let iron: Vec<String> = q.resources.iter().map(|r| r.detail.clone()).collect();
let free = q.price_minor == 0;
let vends = vends.clone();
let period = if q.period.is_empty() { period.clone() } else { q.period.clone() };
view! {
<div class="quote stack" aria-label="Quote">
<dl class="facts">
<dt>"Sells"</dt><dd class="mono">{delta}{if iron.is_empty() { String::new() } else { format!(" — {}", iron.join(", ")) }}</dd>
<dt>"Price"</dt><dd class="mono" title="what the customer pays">{price_words(q.price_minor, &q.currency)}{if free || period.is_empty() { String::new() } else { format!(" / {period}") }}</dd>
<dt>"Cost"</dt><dd class="mono" title="what the cloud provider charges us">{cost_words(q.cost_minor, &q.cost_currency, buys_iron)}</dd>
<dt>"Can serve"</dt><dd><Badge tone=tone label=if q.can_serve { "yes" } else { "no" }/>{(!q.can_serve).then(|| view! { <p class="detail">{serve.trim_start_matches("can serve: no — ").to_owned()}</p> })}</dd>
</dl>
{q.can_serve.then(|| view! {
<div class="row" style="align-items:flex-end">
{(!free && vends.len() > 1).then(|| view! {
<div class="field"><label for="order-vendor">"Vendor"</label>
<select id="order-vendor" on:change=move |ev| vendor.set(event_target_value(&ev))>
{vends.clone().into_iter().map(|v| { let (id, sel) = (v.clone(), v.clone()); view! { <option value=id selected=move || vendor.get() == sel>{v}</option> } }).collect_view()}
</select>
</div>
})}
{(!free).then(|| view! {
<div class="field"><label for="order-payer">"Payer hint (optional)"</label>
<input id="order-payer" type="text" placeholder="+46701234567" prop:value=move || payer.get() on:input=move |ev| payer.set(event_target_value(&ev))/>
</div>
})}
{(!free && vends.is_empty()).then(|| view! { <span class="hint">"No payment provider is set up, so a paid order cannot go through."</span> })}
<button class="btn danger" disabled=move || busy.get() on:click=move |_| submit.run(())>
{move || if busy.get() { "Working…" } else if free { "Confirm: place the order (free)" } else { "Confirm: take the money" }}
</button>
</div>
})}
</div>
}
})}
}.into_any()
}}
{move || started.get().map(|r| view! { <Outcome result=r/> })}
{move || failed.get().map(|f| view! { <FailBox fail=f/> })}
</div>
</Panel>
}
}
/// What a placed order says. PENDING is the normal answer for a vendor that
/// needs the customer, so it is NOT shown as a failure — the redirect is the
/// next step, not an error.
#[component]
fn Outcome(result: Value) -> impl IntoView {
let state = result["state"].as_str().unwrap_or("UNKNOWN").to_owned();
let reference = result["reference"].as_str().unwrap_or_default().to_owned();
let redirect = result["redirect_url"]
.as_str()
.unwrap_or_default()
.to_owned();
let reason = result["reason"].as_str().unwrap_or_default().to_owned();
let tone = match state.as_str() {
"PAID" => Tone::Pass,
"PENDING" => Tone::Live,
"DECLINED" | "RELEASED" => Tone::Bad,
_ => Tone::Warn,
};
view! {
<div class="notice stack">
<div class="row"><StateWord state=state.clone() tone=tone/><span class="mono">{reference}</span></div>
{(!reason.is_empty()).then(|| view! { <p class="hint">{reason}</p> })}
{(!redirect.is_empty()).then(|| { let href = redirect.clone(); view! {
<p class="hint">
"The vendor needs the customer. Send them to "
// rel=noreferrer as well as noopener: a payment URL carries
// the reference, and the vendor need not learn our origin.
<a href=href target="_blank" rel="noopener noreferrer" class="mono">{redirect.clone()}</a>
</p>
}})}
{(state == "PENDING").then(|| view! { <p class="hint">"Not charged yet. It finishes on its own."</p> })}
</div>
}
}
#[component]
fn StateWord(state: String, tone: Tone) -> impl IntoView {
view! { <Badge tone=tone label=state/> }
}