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

nordisk/gunnar-ui

crates/gunnar-ui-web/src/pages/increase.rs

//! **The one panel in this console that leads to money**, and it contains
//! none.
//!
//! Compiled only under `metered`. A community build does not render a disabled
//! button or an explanation — it does not contain this file, so there is
//! nothing to render and nothing to explain.
//!
//! # What is written here, and what is not
//!
//! Every visible string below is either **structural** (`Increase`, `Confirm`,
//! `Continue`, `Cancel` — words about the shape of the interaction) or
//! **monetize's own**, rendered as it arrived. There is no price, no currency,
//! no size, no vendor name and no menu written in this file, and the reason is
//! not squeamishness: the same code has to draw the right thing on a deployment
//! that gives room away and on one that charges for it, and it can only do that
//! if it knows neither.
//!
//! * the BUTTONS come from `GET /api/namespaces/{ns}/increase/bundles`, which is
//!   monetize's `Purchase.Bundles`. The label, the amount and the period on each
//!   one are monetize's own strings, rendered verbatim — this console divides
//!   nothing and maps no currency — and the id goes back on the order without
//!   ever being parsed;
//! * the METERS come from `GET /api/namespaces/{ns}/increase`, which is
//!   monetize's `Products.List` filtered by monetize's own two words;
//! * the TYPED AMOUNT is a number the tenant types, and it is still here. A
//!   deployment that declared no bundles answers with an empty list, and a size
//!   nobody declared can be asked for no other way. It is echoed back through
//!   the same `fmt::bytes` the Room table above uses, so a number means here
//!   what it means there. Which of the two shapes is drawn is
//!   [`crate::offers::room`]'s decision, not this file's;
//! * what the order SELLS is monetize's `delta`, the period is monetize's word,
//!   and a refusal is monetize's sentence;
//! * the LINK is the vendor's, `href` and all. This console does not compose
//!   a URL, does not open a payment form, and loads no vendor script.
//!
//! # Why the panel disappears rather than explaining itself
//!
//! When the route is not there — no `metered` server, or no monetize bearer —
//! the fetch fails and this renders **nothing at all**. Not an error, not a
//! greyed-out button: a console that tells a self-hoster "buying more space is
//! unavailable" has told them there is something to buy, which on their box
//! there is not. Absence is the honest state.
//!
//! # The link is an anchor, not a redirect
//!
//! `Purchase.Start` reserves the money and answers with the vendor's hosted
//! page. The tenant clicks it themselves. A `window.location` assignment would
//! move them without their asking, and the one thing worth being slow about is
//! the step where somebody is about to type a card number — on a page this
//! console does not own, which is exactly where it belongs (the card never
//! touches gunnar, and under SCA a 3-D Secure confirm has no URL to hand back
//! at all unless the vendor hosts the page).
//!
//! What happens after they come back is NOT read from the browser. The vendor
//! tells monetize over its own verified webhook; the return URL's query string
//! is the customer's own claim about whether they paid. So the panel polls
//! `…/increase/status` and believes the ledger.

use leptos::prelude::*;
use serde::Deserialize;

use crate::api::segment;
use crate::fmt;
use crate::ui::Panel;

/// One meter this deployment sells room on, as the server sent it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Sellable {
    pub meter: String,
    pub meaning: String,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Purchasable {
    pub meters: Vec<Sellable>,
    /// The server's sentence when it has no meters and that is not an answer.
    /// See its twin in `api::increase::Purchasable`.
    #[serde(default)]
    pub unavailable: Option<String>,
}

/// What an order would come to. No price: see the module docs, and the server's.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Offer {
    pub reference: String,
    pub can_serve: bool,
    pub reason: String,
    /// The customer's sentence when monetize sent one; shown instead of
    /// `reason`, which is the operator's. Absent from an older server.
    #[serde(default)]
    pub words: String,
    pub period: String,
    #[serde(default)]
    pub delta: std::collections::BTreeMap<String, u64>,
}

/// **An order placed by pressing a button: one opaque id and nothing else.**
///
/// No meter and no number. Everything about the size and the amount was decided
/// on the far side and stays there; the id came out of the server's own list
/// and goes back unexamined.
#[derive(Debug, Clone, serde::Serialize)]
struct BundleOrder {
    bundle: String,
    /// The market the window this was pressed in was priced in — the server's, echoed
    /// ([`crate::offers::market_of_window`]). Absent: monetize's default.
    #[serde(skip_serializing_if = "Option::is_none")]
    market: Option<String>,
}

/// An attempt in flight, and where the tenant goes next.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Attempt {
    pub reference: String,
    pub state: String,
    pub url: String,
    pub reason: String,
}

/// **The panel, on the namespace's own usage page, under the Room table.**
///
/// It renders in three steps and never skips one: what is sellable, what the
/// order would come to, and — only then — the button.
#[component]
pub fn MoreRoom(#[prop(into)] namespace: Signal<String>) -> impl IntoView {
    // Step one. A failure here is silence: the route may simply not exist.
    let purchasable = LocalResource::new(move || {
        let url = format!("/api/namespaces/{}/increase", segment(&namespace.get()));
        async move { crate::api::get::<Purchasable>(&url).await }
    });

    // **What is on sale, priced.** Its own fetch, and a refusal is not an
    // error box: `offers::room` turns an unreadable window into "Awaiting the
    // cloud provider…" while it is asked again, then into the server's
    // sentence and Retry — never the typed order below (owner, 2026-10-05
    // 12:20), and never an empty panel.
    //
    // **FOLLOWED while the server says it is still being read, or refuses**
    // (test2, 2026-10-05) — `subscription::follow_window`, one loop per
    // namespace.
    let window = RwSignal::new(None::<Result<crate::offers::Shop, crate::api::Fail>>);
    let window_late = RwSignal::new(false);
    let window_gen = RwSignal::new(0u32);
    // The market is the server's (the browser's languages, the one rule): no `?market=` from here.
    let window_url = move |ns: String| format!("/api/namespaces/{}/increase/bundles", segment(&ns));
    Effect::new(move |_| {
        let url = window_url(namespace.get());
        window_gen.update(|g| *g = g.wrapping_add(1));
        window.set(None);
        window_late.set(false);
        crate::pages::subscription::follow_window(url, window, window_late, window_gen);
    });
    // **Retry** (a window refused for a minute): the wait starts again.
    let retry_window = Callback::new(move |()| {
        window_gen.update(|g| *g = g.wrapping_add(1));
        window_late.set(false);
        crate::pages::subscription::follow_window(window_url(namespace.get_untracked()), window, window_late, window_gen);
    });

    // What the tenant asked for. `cap` is a TARGET — the number they want to be
    // allowed — because that is what monetize's order message is; the console
    // never subtracts, and `delta` in the answer is monetize's subtraction.
    let (meter, set_meter) = signal(String::new());
    let (cap, set_cap) = signal(String::new());
    let (offer, set_offer) = signal(None::<Result<Offer, crate::api::Fail>>);
    let (attempt, set_attempt) = signal(None::<Attempt>);
    // The chosen offer's price line, for beside the pay button.
    let (chosen_price, set_chosen_price) = signal(String::new());
    let (busy, set_busy) = signal(false);
    // True once the browser has been sent to the vendor: the panel says so
    // instead of drawing an order it is leaving.
    let (leaving, set_leaving) = signal(false);

    // Every change to the numbers throws the last answer away, so a Confirm
    // button can never sit under a stale one.
    Effect::new(move |_| {
        let _ = (meter.get(), cap.get());
        set_offer.set(None);
        set_attempt.set(None);
    });

    let ask = move || {
        let (ns, meter, cap) = (namespace.get(), meter.get(), cap.get());
        let Ok(cap) = cap.trim().parse::<u64>() else {
            return;
        };
        if meter.is_empty() {
            return;
        }
        set_busy.set(true);
        leptos::task::spawn_local(async move {
            let url = format!(
                "/api/namespaces/{}/increase/offer?meter={}&cap={cap}",
                segment(&ns),
                segment(&meter)
            );
            set_offer.set(Some(crate::api::get::<Offer>(&url).await));
            set_busy.set(false);
        });
    };

    let confirm = move || {
        let (ns, meter, cap) = (namespace.get(), meter.get(), cap.get());
        let Ok(cap) = cap.trim().parse::<u64>() else {
            return;
        };
        set_busy.set(true);
        leptos::task::spawn_local(async move {
            let url = format!("/api/namespaces/{}/increase", segment(&ns));
            let body = serde_json::json!({ "meter": meter, "cap": cap });
            match crate::api::post::<_, Attempt>(&url, &body).await {
                // Straight to the vendor, same tab (owner 2026-09-28: everywhere,
                // not only on the account page): the order is shown only when there
                // is no link to follow or the browser would not go.
                Ok(started) => match crate::purchase::go_to_vendor(crate::purchase::phase(&started.state, false), &started.url) {
                    Some(url) if crate::pages::subscription::navigate_to(&url) => set_leaving.set(true),
                    _ => set_attempt.set(Some(started)),
                },
                // The refusal keeps the server's words. Since 2026-09-20 those
                // are monetize's CUSTOMER words when it sent them (the server
                // reads them off the status, `grpc::Fail::from`) and its
                // operator's words verbatim when it did not — never a sentence
                // this console made up. "refused" is what happened; "unknown"
                // was a word about the ledger and read as a broken state.
                Err(fail) => set_attempt.set(Some(Attempt {
                    reference: String::new(),
                    state: "refused".to_owned(),
                    url: String::new(),
                    reason: fail.message,
                })),
            }
            set_busy.set(false);
        });
    };

    // **Which of the two shapes the panel is in**, decided by
    // [`crate::offers::room`] and not here. This closure only says which of the
    // three states the read is in, which is the one thing the page knows.
    let shape = move || match window.get() {
        None => crate::offers::room(crate::offers::Window::Waiting),
        Some(Err(fail)) if window_late.get() => {
            crate::offers::room(crate::offers::Window::GaveUp(&crate::offers::refusal_words(fail.status, &fail.message, fail.summary())))
        }
        Some(Err(_)) => crate::offers::room(crate::offers::Window::Refused),
        Some(Ok(shop)) if window_late.get() => crate::offers::room(crate::offers::Window::Late(&shop)),
        Some(Ok(shop)) => crate::offers::room(crate::offers::Window::Listed(&shop)),
    };

    // Press one. **No quote step**, and that is not a shortcut: `can_serve` on
    // the row IS the quote — monetize runs the same `quote_order` a typed order
    // goes through to answer it, on the caps this tenant would end up with. The
    // button a tenant presses is already under the answer to the question they
    // asked. Everything after it is the ordinary path: the same
    // `Purchase.Start`, the same reference, the vendor's own link, the same
    // poll.
    // `price` and `period` are monetize's words for the chosen offer; the pay
    // button drawn when the browser could not leave says them with the VAT.
    let order = move |id: String, price_line: String| {
        set_chosen_price.set(price_line);
        let ns = namespace.get();
        set_busy.set(true);
        leptos::task::spawn_local(async move {
            let url = format!("/api/namespaces/{}/increase/bundle", segment(&ns));
            let market = match window.get_untracked() {
                Some(Ok(shop)) => crate::offers::market_of_window(&shop),
                _ => None,
            };
            let body = BundleOrder { bundle: id, market };
            match crate::api::post::<BundleOrder, Attempt>(&url, &body).await {
                // Straight to the vendor, same tab (owner 2026-09-28: everywhere,
                // not only on the account page): the order is shown only when there
                // is no link to follow or the browser would not go.
                Ok(started) => match crate::purchase::go_to_vendor(crate::purchase::phase(&started.state, false), &started.url) {
                    Some(url) if crate::pages::subscription::navigate_to(&url) => set_leaving.set(true),
                    _ => set_attempt.set(Some(started)),
                },
                // The refusal keeps the server's words. Since 2026-09-20 those
                // are monetize's CUSTOMER words when it sent them (the server
                // reads them off the status, `grpc::Fail::from`) and its
                // operator's words verbatim when it did not — never a sentence
                // this console made up. "refused" is what happened; "unknown"
                // was a word about the ledger and read as a broken state.
                Err(fail) => set_attempt.set(Some(Attempt {
                    reference: String::new(),
                    state: "refused".to_owned(),
                    url: String::new(),
                    reason: fail.message,
                })),
            }
            set_busy.set(false);
        });
    };

    let poll = move || {
        let (ns, reference) = (
            namespace.get(),
            attempt.get().map(|a| a.reference).unwrap_or_default(),
        );
        if reference.is_empty() {
            return;
        }
        leptos::task::spawn_local(async move {
            let url = format!(
                "/api/namespaces/{}/increase/status?reference={}",
                segment(&ns),
                segment(&reference)
            );
            if let Ok(now) = crate::api::get::<Attempt>(&url).await {
                set_attempt.set(Some(now));
            }
        });
    };

    move || {
        // **THE SILENCE, and its one honest case.** No ROUTE means this
        // deployment does not sell: a community build, or a metered one with no
        // monetize credential. Nothing is rendered, for the reason the module
        // docs give — telling a self-hoster that buying is unavailable has told
        // them there is something to buy.
        //
        // A route that ANSWERS is a different matter, and it used to end here
        // too. MEASURED on gunnar.rs 2026-09-17: the panel drew a blank
        // rectangle, and the owner — an admin, on a box that sells — had no
        // sentence to act on. The server now says which of the two it is, so
        // the silence covers only the case it was argued for.
        let Some(Ok(purchasable)) = purchasable.get() else {
            return ().into_any();
        };
        if let Some(why) = purchasable.unavailable.clone() {
            return view! {
                <Panel title="More room">
                    <div class="panel-body stack">
                        <p class="hint">{why}</p>
                    </div>
                </Panel>
            }
            .into_any();
        }
        if purchasable.meters.is_empty() {
            return ().into_any();
        }
        let meters = purchasable.meters.clone();
        if meter.get_untracked().is_empty() {
            if let Some(first) = meters.first() {
                set_meter.set(first.meter.clone());
            }
        }
        view! {
            <Panel title="More room">
                <div class="panel-body stack">
                    <p class="hint">
                        "What this box will make room for, and what it would take. The numbers \
                         are the ones the table above counts."
                    </p>
                    {move || match shape() {
                        // Still asking. Neither shape is claimed yet: drawing
                        // the boxes and then swapping them for buttons would
                        // move a control out from under a pointer.
                        crate::offers::Room::Waiting => ().into_any(),

                        // Still being read (test2, 2026-10-05): the spinner
                        // and the server's line while the window is asked
                        // again — never the boxes.
                        crate::offers::Room::Awaiting(line) => view! {
                            <crate::pages::subscription::Awaiting line=line />
                        }.into_any(),

                        // Refused for a minute: the server's sentence and
                        // Retry — never the boxes (owner, 2026-10-05 12:20).
                        crate::offers::Room::Retry(line) => view! {
                            <crate::pages::subscription::Unreadable line=line retry=retry_window />
                        }.into_any(),

                        // ── THE ONE LINE ───────────────────────────────────
                        //
                        // The shop has rows and this box can serve none of
                        // them to this account. The server's sentence, and no
                        // boxes under it: a typed size against a shop the
                        // server just called full would only be refused.
                        crate::offers::Room::Nothing(line) => view! {
                            <p class="hint">{line}</p>
                        }.into_any(),

                        // ── THE BUTTONS ────────────────────────────────────
                        //
                        // One per offer, in monetize's order, which is the
                        // operator's declared order and not this console's to
                        // sort. Every word on a row is monetize's: the label it
                        // composed from the delta, the amount it rendered as
                        // words, the period it named. This console re-renders
                        // exactly one field — the delta, through the same
                        // `fmt::bytes` the Room table above uses — and parses
                        // the id never.
                        crate::offers::Room::Offered(rows) => view! {
                            <div class="stack offers">
                                {rows.into_iter().map(|b| {
                                    let id = b.id.clone();
                                    match crate::offers::row(&b) {
                                        // ONE button per offer, its words and
                                        // price ON it, nothing loose beside it
                                        // (owner, 2026-10-02) — the customer's
                                        // panel's shape; the words are the server's (A32-29).
                                        crate::offers::Row::Press(_) => {
                                            let words = b.offer_words.clone();
                                            let price_line = b.price_line.clone();
                                            view! {
                                                <button
                                                    class=crate::purchase::OFFER_CLASS
                                                    disabled=move || busy.get()
                                                    on:click=move |_| order(id.clone(), price_line.clone())
                                                >
                                                    {words}
                                                </button>
                                            }.into_any()
                                        }
                                        // ★ AN OFFER THAT DOES NOT FIT: shown —
                                        // its own words, greyed, as TEXT, no
                                        // button — and monetize's sentence under
                                        // it, verbatim (the customer panel's
                                        // shape, owner test2 2026-10-05).
                                        crate::offers::Row::Marked { why, .. } => {
                                            let words = b.offer_words.clone();
                                            let why = why.to_owned();
                                            view! {
                                                <div class="offer-marked" aria-disabled="true">
                                                    <p class="offer-marked-words">{words}</p>
                                                    <p class="hint offer-why">{why}</p>
                                                </div>
                                            }.into_any()
                                        }
                                    }
                                }).collect::<Vec<_>>()}
                                <p class="hint">{crate::purchase::panel_vat_note()}</p>
                            </div>
                        }.into_any(),

                        // ── THE TYPED ORDER, kept ──────────────────────────
                        //
                        // The only way to ask for a size nobody declared, and
                        // the shape every deployment that declared none is in.
                        // Quote first, then confirm: unchanged.
                        crate::offers::Room::Typed => view! {
                    <div class="row">
                        <label>
                            "Meter"
                            <select
                                on:change=move |ev| set_meter.set(event_target_value(&ev))
                                prop:value=move || meter.get()
                            >
                                {meters
                                    .iter()
                                    .map(|m| {
                                        let (name, meaning) = (m.meter.clone(), m.meaning.clone());
                                        let label = format!("{name} \u{2014} {meaning}");
                                        view! { <option value=name>{label}</option> }
                                    })
                                    .collect_view()}
                            </select>
                        </label>
                        <label>
                            "Allowed, after"
                            <input
                                type="number"
                                min="0"
                                on:input=move |ev| set_cap.set(event_target_value(&ev))
                                prop:value=move || cap.get()
                            />
                        </label>
                        // The typed number, spelled the way the table above
                        // spells its numbers, so the tenant can see what they
                        // just asked for before anything is quoted.
                        <p class="meta">
                            {move || cap
                                .get()
                                .trim()
                                .parse::<u64>()
                                .map(fmt::bytes)
                                .unwrap_or_default()}
                        </p>
                        <button
                            class="btn"
                            disabled=move || busy.get()
                            on:click=move |_| ask()
                        >
                            "Increase"
                        </button>
                    </div>

                    {move || match offer.get() {
                        None => ().into_any(),
                        Some(Err(fail)) => view! { <p class="meta">{fail.message}</p> }.into_any(),
                        Some(Ok(offer)) => {
                            // J7: the customer's words, never the meter id.
                            let delta: Vec<(String, u64)> = offer.delta.iter().map(|(m, a)| (m.clone(), *a)).collect();
                            let sells = crate::purchase::sells_words(&delta, &|b| fmt::bytes(b));
                            let period = offer.period.clone();
                            let reason = if offer.words.is_empty() { offer.reason.clone() } else { offer.words.clone() };
                            let can = offer.can_serve;
                            view! {
                                <dl class="metrics">
                                    <div>
                                        <dt>"Sells"</dt>
                                        <dd>{sells}</dd>
                                    </div>
                                    <div>
                                        <dt>"Period"</dt>
                                        <dd>{period}</dd>
                                    </div>
                                </dl>
                                // can_serve false: the sentence and no button.
                                // The wall is the product's, the cloud's or the
                                // box's, and it is quoted rather than summarised.
                                {(!can).then(|| view! { <p class="hint">{reason.clone()}</p> })}
                                {can
                                    .then(|| {
                                        view! {
                                            <button
                                                class="btn primary"
                                                disabled=move || busy.get()
                                                on:click=move |_| confirm()
                                            >
                                                "Confirm"
                                            </button>
                                        }
                                    })}
                            }
                            .into_any()
                        }
                    }}
                        }.into_any(),
                    }}

                    {move || leaving.get().then(|| view! {
                        <p class="hint" aria-live="polite">{crate::purchase::LEAVING}</p>
                    })}

                    {move || match attempt.get() {
                        None => ().into_any(),
                        Some(attempt) => {
                            let state = attempt.state.clone();
                            let reason = attempt.reason.clone();
                            let url = attempt.url.clone();
                            view! {
                                <p class="meta">{state}</p>
                                {(!reason.is_empty()).then(|| view! { <p class="hint">{reason.clone()}</p> })}
                                // THE LINK. Its href is the vendor's, byte for
                                // byte, and this console composed none of it.
                                // `rel` because the page it opens is not ours.
                                // The ONE pay button (`pages::subscription::
                                // PayButton`), with the chosen offer's price.
                                {(!url.is_empty())
                                    .then(|| {
                                        view! {
                                            <crate::pages::subscription::PayButton
                                                url=url.clone()
                                                price=chosen_price.get()
                                            />
                                        }
                                    })}
                                <button class="btn quiet" on:click=move |_| poll()>
                                    "Check again"
                                </button>
                            }
                            .into_any()
                        }
                    }}
                </div>
            </Panel>
        }
        .into_any()
    }
}