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/subscription.rs

//! **A customer's own account: the plan, the disk, and the two things they can
//! do about either.**
//!
//! Compiled only under `metered`. A community build does not render a disabled
//! pane or an explanation — it does not contain this file.
//!
//! # Why this is one module and not five panels scattered about
//!
//! Everything here answers one question — *what do I have, and what can I do
//! about it* — and every route it reads is under `/api/account/`, which is the
//! surface whose safety comes from a single property:
//!
//! > every handler under it acts on the name in the SESSION, and no path
//! > segment, query parameter or body field can change it.
//!
//! Split across pages that each took a namespace, that property would have to
//! be re-argued at every one of them, and each would be a place to get it
//! wrong. Here there is no namespace to pass: this file never sends one, never
//! reads one out of the route, and has no way to name another account even by
//! mistake.
//!
//! # What is written here, and what is not
//!
//! Every visible string below is either **structural** — words about the
//! shape of the interaction — or **the server's own**, rendered as it
//! arrived. There is no price and no menu of sizes composed in this file: the
//! amount and currency are monetize's words. Since 2026-09-28 (owner, reading
//! the live pane) the customer's order is told in customer words —
//! "storage" and never a meter id, the price with its period and "+ VAT",
//! "Pay with Stripe", the three steps, and never monetize's operator
//! `reason` — and every one of those sentences lives in [`crate::purchase`],
//! host-side, where it is tested.
//!
//! The one that is easy to get wrong is the *good* news. When an order is
//! granted, the customer must be told the room is already theirs and must not
//! be led to expect provisioning — because a purchase never buys cloud disk, it
//! raises the cap at once out of the free room, and what it buys later is only
//! a queued evaluation. That is a policy this console cannot know, so it does
//! not write the sentence: monetize sends it as `words` and this renders it
//! verbatim. A cheerful string invented here would be the one lie the whole
//! path is arranged to avoid.
//!
//! # The plan is read even when nothing can be sold
//!
//! `/api/account/plan` and `/api/account/storage` are gunnar's answers about
//! the caller's own account and are not gated on whether this monetize can take
//! money. A customer whose renewal path is unavailable still has the right to
//! see that their plan has lapsed — that is exactly when they need to see it.
//! Only the two BUTTONS depend on the selling half being there, and when it is
//! not they are simply absent, for the reason `increase.rs` gives: a console
//! that says "renewing is unavailable" has told a self-hoster there is
//! something to renew.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};

use crate::fmt;
use crate::ui::{Badge, FailBox, Loading, Metric, Panel, Tone};

/// How often an open order is asked about, and for how long: 3 s, for 8
/// minutes — monetize's poller captures a paid order within its 300 s period
/// when the vendor's webhook never comes (MEASURED on the KVM mock
/// 2026-09-28: `CAPTURED by monetize's POLLER alone … 300 s after the
/// payment`), so the follow outlives the slowest honest answer.
const FOLLOW_MS: u32 = 3_000;
const FOLLOW_POLLS: u32 = 160;

/// What renewing would come to. **No price** — see the module docs.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Renewal {
    pub reference: String,
    pub can_serve: bool,
    pub reason: String,
    pub words: String,
    pub period: String,
    /// What renewing costs, in monetize's words. Rendered as it arrived.
    #[serde(default)]
    pub price_words: String,
    #[serde(default)]
    pub caps: std::collections::BTreeMap<String, u64>,
    /// The server's answer that this account has bought nothing, so there is
    /// nothing to renew.
    #[serde(default)]
    pub nothing_to_renew: bool,
    /// A32-29: the Prolong button's price line, the server's (monetize-site-buy).
    #[serde(default)]
    pub price_line: String,
    /// A32-29: the customer's sentence when it cannot be prolonged, the server's.
    #[serde(default)]
    pub line: String,
}

/// Where the customer is sent, and what they are told when it is done.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Attempt {
    pub reference: String,
    pub state: String,
    pub url: String,
    /// monetize's OPERATOR words. Never rendered to a customer (owner,
    /// 2026-09-28: no log-line sentences); the phase says what is happening.
    pub reason: String,
    #[serde(default)]
    pub words: String,
    /// The caps before and after this order, as the server made it.
    #[serde(default)]
    pub before: std::collections::BTreeMap<String, u64>,
    #[serde(default)]
    pub after: std::collections::BTreeMap<String, u64>,
    /// The ledger holds the capture: paid, room on its way.
    #[serde(default)]
    pub captured: bool,
    /// What the chosen offer costs, monetize's words, and its period — for the
    /// price beside the pay button. Empty when the server did not send them.
    #[serde(default)]
    pub price_words: String,
    #[serde(default)]
    pub period: String,
    /// A32-29: the price line beside the pay button, the server's.
    #[serde(default)]
    pub price_line: String,
    /// **monetize's word for where the order is** (thin layer, owner
    /// 2026-10-04) — `waiting_for_payment`, `paid_adding`, `done`, … —
    /// empty on an answer that carried none (a `Start`).
    #[serde(default)]
    pub phase: String,
    /// monetize says the order can be cancelled now: the "Cancel order"
    /// button is drawn on this and nothing else.
    #[serde(default)]
    pub cancellable: bool,
    /// "Add users" (owner, 2026-10-04): what the order is for, and whether
    /// the users exist yet — the server's words and numbers.
    #[serde(default)]
    pub users: Option<UsersLine>,
    /// Where the room a paid order bought stands: the server's state word and
    /// the sentence the buyer is shown, live.
    #[serde(default)]
    pub room: Option<RoomLine>,
    /// monetize's verb for the order (`prolong` | `renew` | `upgrade` |
    /// `bump` | `start`), empty for room alone — `crate::purchase::kind_of`.
    #[serde(default)]
    pub action: String,
    /// The granted fact's end, on a paid order.
    #[serde(default)]
    pub paid_until_unix_ms: Option<u64>,
    /// A32-29: that day in words, the server's (monetize-site-buy `date_words`); empty when
    /// the order names no day.
    #[serde(default)]
    pub paid_until_words: String,
}

#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct UsersLine {
    #[serde(default)]
    pub principal: String,
    #[serde(default)]
    pub seats: u32,
    #[serde(default)]
    pub room_bytes: u64,
    #[serde(default)]
    pub bought_bytes: u64,
    #[serde(default)]
    pub granted: bool,
    #[serde(default)]
    pub invited: String,
    #[serde(default)]
    pub refunded: bool,
    #[serde(default)]
    pub reason: String,
}

#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct RoomLine {
    pub state: String,
    pub words: String,
}

impl RoomLine {
    /// Still moving: the page keeps asking until the room is done.
    pub fn moving(&self) -> bool {
        matches!(self.state.as_str(), "queued" | "enlarging" | "waiting_for_capacity")
    }
}

/// `{"reference": …}` — which order to cancel.
#[derive(Debug, Clone, Serialize)]
struct Which {
    reference: String,
}

/// One past purchase, folded to its outcome.
///
/// **No amount**, and that is this console's limit rather than a refusal by
/// monetize: it sends what the customer was charged, and the console cannot
/// render `Money { minor, currency }` without the division and the currency
/// symbol it may not contain. A row says what was bought and when; the amounts
/// are on the vendor's own pages, where the card is.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Past {
    pub reference: String,
    pub outcome: String,
    pub at_unix_ms: u64,
    /// A32-29: that day, the server's words (`date_words`).
    #[serde(default)]
    pub date_words: String,
    #[serde(default)]
    pub delta: std::collections::BTreeMap<String, u64>,
    pub steps: u32,
    /// **What was charged, in monetize's words** — rendered exactly as it
    /// arrived. Empty when monetize sent none, and then nothing is drawn.
    #[serde(default)]
    pub price_words: String,
}

/// **The account pane**: the plan, the renewal and the past purchases. Takes
/// no namespace, by construction — see the module docs.
#[component]
pub fn Subscription() -> impl IntoView {
    // Bumped when a followed order leaves `pending`, so the plan, the disk and
    // the history are read again and show the cap the order raised.
    let reload = RwSignal::new(0u32);
    // ★ **The plan, as monetize answers it** (owner 2026-10-17): the gauge of
    // monetize's window — its label, its storage, the term it is paid until —
    // and from its state the ONE button ([`crate::purchase::account_button`]).
    // Followed while monetize is still reading; read again when an order lands.
    let window = RwSignal::new(None::<Result<crate::offers::Shop, crate::api::Fail>>);
    let (window_late, window_gen) = (RwSignal::new(false), RwSignal::new(0u32));
    follow_window("/api/account/subscription/bundles".to_owned(), window, window_late, window_gen);
    Effect::new(move |seen: Option<u32>| {
        let now = reload.get();
        if seen.is_some_and(|s| s != now) {
            window_gen.update(|g| *g += 1);
            follow_window("/api/account/subscription/bundles".to_owned(), window, window_late, window_gen);
        }
        now
    });

    // The selling half. A failure is silence, not an error: the route may
    // simply not be there, and on a box that sells nothing it should not be.
    let renewal = LocalResource::new(move || async move { crate::api::get::<Renewal>("/api/account/subscription").await });


    // What this account has already been sold. Its own fetch, so a failure
    // here leaves the rest of the pane standing: a customer who cannot see
    // their history can still renew.
    let history = LocalResource::new(move || {
        reload.track();
        async move { crate::api::get::<Vec<Past>>("/api/account/subscription/history").await }
    });
    // The fold's ledger count is the OPERATOR's read, not the customer's: an
    // unlabeled number in a customer's purchase row said nothing to them
    // (release-2, owner 2026-09-28). Drawn only for an admin session.
    let session = crate::app::session();
    let is_admin = Memo::new(move |_| session.get().is_some_and(|s| s.is_admin()));

    let (attempt, set_attempt) = signal(None::<Attempt>);
    // True once the browser has been sent to the vendor: the page says so
    // instead of drawing an order it is leaving.
    let (leaving, set_leaving) = signal(false);
    let (fault, set_fault) = signal(None::<crate::api::Fail>);
    let (busy, set_busy) = signal(false);

    // ── FOLLOWING AN OPEN ORDER (buyflow, 2026-09-28) ─────────────────────
    //
    // MEASURED on test2 and on the KVM mock: the order panel was set ONCE,
    // from the answer to the press, and never asked again. The customer went
    // to the vendor's page with the old Continue link and came back to a page that knew
    // nothing (the return address was `/repos` live, `/paid/<ref>` on the
    // bench — a route this console does not have), so "pending" was the last
    // word they ever saw, even after monetize had captured the money and the
    // cap was raised.
    //
    // Now a pending order is FOLLOWED: `Purchase.Status` through
    // `/api/account/subscription/status` (the account's own, scoped by the
    // session) every `FOLLOW_MS`, until monetize says anything but `pending`
    // — the webhook's capture, or the poller's within its period when no
    // webhook came — and then the plan, the disk and the history are read
    // again. The vendor's return lands on `/paid/<reference>`, which
    // is this pane following that reference from the first paint. The words
    // stay the server's: the badge is monetize's state, the sentence its own.
    let following = RwSignal::new(false);
    let follow = move |reference: String| {
        if reference.is_empty() || following.get_untracked() {
            return;
        }
        following.set(true);
        leptos::task::spawn_local(async move {
            let path = format!("/api/account/subscription/status?reference={}", crate::auth::percent_encode(&reference));
            for _ in 0..FOLLOW_POLLS {
                match crate::api::get::<Attempt>(&path).await {
                    Ok(now) => {
                        let open = now.state == "pending";
                        // The status carries no granted sentence (monetize's
                        // `words` belong to the answer to the press); keep the
                        // one the press was given, if this is that order.
                        set_attempt.update(|a| {
                            let words = a.as_ref().filter(|a| a.reference == now.reference).map(|a| a.words.clone()).unwrap_or_default();
                            *a = Some(Attempt { words: if now.words.is_empty() { words } else { now.words.clone() }, ..now });
                        });
                        if !open {
                            reload.update(|n| *n += 1);
                            break;
                        }
                    }
                    Err(e) => {
                        set_fault.set(Some(e));
                        break;
                    }
                }
                gloo_timers::future::TimeoutFuture::new(FOLLOW_MS).await;
            }
            following.set(false);
        });
    };
    // The vendor's return: `/paid/<reference>`.
    let params = leptos_router::hooks::use_params_map();
    Effect::new(move |_| {
        if let Some(reference) = params.with(|p| p.get("reference")) {
            follow(reference.trim_start_matches('/').to_owned());
        }
    });
    // Whatever the press answered, followed while it is open.
    let placed = move |a: Attempt| {
        let open = a.state == "pending";
        let reference = a.reference.clone();
        set_attempt.set(Some(a));
        if open {
            follow(reference);
        }
    };

    let renew = move |_| {
        set_busy.set(true);
        set_fault.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post_empty::<Attempt>("/api/account/subscription/renew").await {
                Ok(a) => {
                    let p = crate::purchase::phase_of(&a.phase, &a.state, a.captured);
                    match crate::purchase::go_to_vendor(p, &a.url) {
                        Some(url) if navigate_to(&url) => set_leaving.set(true),
                        _ => placed(a),
                    }
                }
                Err(e) => set_fault.set(Some(e)),
            }
            set_busy.set(false);
        });
    };

    // The renewal: the Prolong button inside the Plan panel, when monetize
    // sells another term of what the account holds.
    let prolong = move || match renewal.get() {
        None => ().into_any(),
        Some(Err(_)) => view! { <p class="muted" id="plan-prolong-refused">{crate::purchase::PROLONG_REFUSED}</p> }.into_any(),
        Some(Ok(r)) => match crate::purchase::prolong_line(r.can_serve, &r.price_line, &r.line) {
            crate::purchase::RenewalLine::Renew(said) => {
                view! {
                    <div class="row tier-more">
                        <button class="btn" id="account-prolong" on:click=renew disabled=move || busy.get()>
                            {crate::purchase::PROLONG_LABEL}
                        </button>
                        {(!said.is_empty()).then(|| view! { <span class="muted">{said}</span> })}
                    </div>
                }
                .into_any()
            }
            crate::purchase::RenewalLine::NothingYet => ().into_any(),
            crate::purchase::RenewalLine::Said(said) => view! { <p class="muted">{said}</p> }.into_any(),
        },
    };

    // Memos, so the panel (and its button) is drawn again only when what it
    // shows changes — never under a pointer for a session re-read.
    let answered = Memo::new(move |_| window.get());
    let door = Memo::new(move |_| session.get().and_then(|s| crate::purchase::signup_door(s.buy_url)));

    view! {
        <div class="stack">
            // ── ONE panel: the plan (owner 2026-10-17: "Plan", never
            // "Subscription" — a term is paid once, it is no subscription).
            // Free: "Free" and the big "Buy a plan"; a plan: its name, its
            // storage, the term it is paid until, and the big "Grow your
            // account". Every word about the plan is monetize's.
            <Panel title=crate::purchase::PLAN_PANEL.to_owned()>
                <div class="panel-body tier" id="account-plan">
                    {move || match answered.get() {
                        Some(Ok(shop)) if shop.plan.is_some() || shop.pending.is_none() => {
                            let g = shop.plan.clone().unwrap_or_default();
                            let label = crate::purchase::account_button(Some(g.state.as_str()));
                            let planned = label == crate::purchase::GROW_YOUR_ACCOUNT;
                            let door = door.get();
                            let lapsed = planned && g.state != "paid";
                            view! {
                                <p class="tier-name" id="plan-name">
                                    <strong>{if planned { g.label.clone() } else { crate::purchase::FREE_PLAN.to_owned() }}</strong>
                                </p>
                                {(planned && (g.cap_bytes > 0 || g.paid_until_unix_ms > 0)).then(|| view! {
                                    <dl class="metrics">
                                        {(g.cap_bytes > 0).then(|| view! { <Metric label="Storage".to_owned() value=fmt::bytes(g.cap_bytes) /> })}
                                        {(!g.paid_until_words.is_empty()).then(|| view! { <Metric label="Paid until".to_owned() value=g.paid_until_words.clone() /> })}
                                    </dl>
                                })}
                                // monetize's own line for a term that has
                                // ended (grace, read-only), as it sent it.
                                {(lapsed && !g.ends_words.is_empty()).then(|| view! { <p class="tier-ends">{g.ends_words.clone()}</p> })}
                                {(!g.banner.is_empty()).then(|| view! {
                                    <p class=if g.banner_tone == "bad" { "room-banner bad" } else { "room-banner warn" } role="status">{g.banner.clone()}</p>
                                })}
                                {door.map(|door| view! {
                                    // `rel="external"`: the sign-up page is
                                    // the server's — the browser loads it.
                                    <div class="account-cta">
                                        <a class="btn primary account-cta-btn" id="account-buy-grow" rel="external" href=door>{label}</a>
                                    </div>
                                })}
                                {planned.then(prolong)}
                            }
                            .into_any()
                        }
                        Some(Err(fail)) if window_late.get_untracked() => view! {
                            <p class="muted">{crate::offers::refusal_words(fail.status, &fail.message, fail.summary())}</p>
                        }
                        .into_any(),
                        _ => view! { <Loading rows=2 /> }.into_any(),
                    }}
                </div>
            </Panel>

            // ── What is used: the room and each repository's share, read
            // only, under the Plan (owner 2026-10-17).
            <crate::pages::my_storage::OwnSpace/>

            // ── What has already been bought ───────────────────────────────
            {move || history.get().and_then(|h| h.ok()).filter(|rows| !rows.is_empty()).map(|rows| { let admin = is_admin.get(); view! {
                <Panel title="Past purchases".to_owned()>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {rows.into_iter().map(|row| {
                                    // A renewal sells no new room, so an empty
                                    // delta IS the difference between the two — the
                                    // server's fact, not a word chosen here.
                                    let what = if row.delta.is_empty() {
                                        "another period".to_owned()
                                    } else {
                                        row.delta.values().map(|n| fmt::bytes(*n)).collect::<Vec<_>>().join(", ")
                                    };
                                    view! {
                                        <tr>
                                            <td>{row.date_words.clone()}</td>
                                            <td>{what}</td>
                                            // monetize's own rendered amount, and
                                            // nothing drawn when it sent none.
                                            // This console formats no money: it
                                            // could not, without the division and
                                            // the currency symbol it may not
                                            // contain.
                                            <td>{row.price_words.clone()}</td>
                                            <td><Badge tone=Tone::Plain label=row.outcome.clone() /></td>
                                            // Still open at the vendor (the tab was
                                            // closed at Stripe): the real button.
                                            <td>{crate::purchase::resumable(&row.outcome).then(|| view! {
                                                <ResumeButton reference=row.reference.clone() />
                                            })}</td>
                                            // The fold's own count — how many ledger
                                            // rows are behind this one line — for an
                                            // administrator only, and labelled.
                                            {admin.then(|| view! {
                                                <td class="muted">{ledger_steps(row.steps)}</td>
                                            })}
                                        </tr>
                                    }
                                }).collect::<Vec<_>>()}
                            </tbody>
                        </table>
                    </div>
                </Panel>
            }})}

            // ── What happened ──────────────────────────────────────────────
            {move || fault.get().map(|fail| view! { <FailBox fail=fail /> })}
            {move || leaving.get().then(|| view! {
                <Panel title="Your order".to_owned()><p aria-live="polite">{crate::purchase::LEAVING}</p></Panel>
            })}
            {move || attempt.get().map(|a| view! {
                // Only when the browser did NOT leave for the vendor: no link
                // yet, or the navigation failed — then a real button.
                <FollowOrder initial=a.clone() fresh=true price=a.price_line.clone() />
            })}
        </div>
    }
}

/// **A window still being read**: a spinner and the server's line, nothing to
/// press. Shared with the operator's panel (`pages::increase`).
#[component]
pub fn Awaiting(line: String) -> impl IntoView {
    view! {
        <div class="panel-body awaiting" aria-busy="true" aria-live="polite">
            <span class="spinner" aria-hidden="true"></span>
            <span class="muted">{line}</span>
        </div>
    }
}

/// **A window refused for a minute**: the server's sentence and a Retry
/// button that starts the wait again — nothing to type into. Shared with the
/// operator's panel (`pages::increase`).
#[component]
pub fn Unreadable(line: String, retry: Callback<()>) -> impl IntoView {
    view! {
        <div class="panel-body stack unreadable" aria-live="polite">
            <p class="muted">{line}</p>
            <div class="row">
                <button class="primary retry" on:click=move |_| retry.run(())>"Retry"</button>
            </div>
        </div>
    }
}

/// **Read the shop window at `url`, and follow it while the server says it is
/// still being read** (`offers::Shop::pending`, test2 2026-10-05) **or
/// refuses it** (owner, 2026-10-05 12:20).
///
/// Still being read: asks again on the server's schedule (`offers::next_poll_ms`) — every 2 s for
/// a minute, then every 10 s with `late` set, then not at all. Refused: asks
/// again after `offers::retry_refused_ms` — every 2 s for a minute, then
/// stops with `late` set, which the page draws as the server's sentence and
/// Retry. Stops the moment an answer is neither. The minute is the CLOCK's,
/// not the sum of the waits: a refused read can itself take seconds. Every
/// answer is written to `into` as it comes; the decision about what to draw
/// stays `offers::room`'s. `generation` is the caller's: bump it before
/// calling again (a new namespace, Retry) and the old loop stops at its next
/// answer; a page that is gone stops it too.
pub fn follow_window(
    url: String,
    into: RwSignal<Option<Result<crate::offers::Shop, crate::api::Fail>>>,
    late: RwSignal<bool>,
    generation: RwSignal<u32>,
) {
    let Some(mine) = generation.try_get_untracked() else { return };
    leptos::task::spawn_local(async move {
        let began = js_sys::Date::now();
        // A32-29: the server's schedule, from the last window it answered with one.
        let mut poll: Option<crate::offers::Poll> = None;
        loop {
            let got = crate::api::get::<crate::offers::Shop>(&url).await;
            if generation.try_get_untracked() != Some(mine) {
                return;
            }
            let asked_ms = (js_sys::Date::now() - began).max(0.0) as u32;
            if let Ok(shop) = &got {
                poll = shop.poll.or(poll);
            }
            let wait = match &got {
                Ok(shop) if crate::offers::pending(shop) => match &poll {
                    Some(p) => crate::offers::next_poll_ms(p, asked_ms),
                    None => crate::offers::retry_refused_ms(asked_ms),
                },
                Ok(_) => None,
                Err(_) => crate::offers::retry_refused_ms(asked_ms),
            };
            if late.try_set(crate::offers::is_late(poll.as_ref(), asked_ms)).is_some() || into.try_set(Some(got)).is_some() {
                return;
            }
            let Some(wait) = wait else { return };
            gloo_timers::future::TimeoutFuture::new(wait).await;
        }
    });
}

/// **Your purchases** (one shop, owner 2026-10-07): what this account bought
/// here, LEADING with its private Gunnars — the name, the step and what is
/// laid, the month, the paid period, Grow and Open — every word the server's
/// (`GET /api/account/purchases`). Then the account's room as before; an
/// account WITHOUT room here has no plan pane (never "0 B", never an empty
/// bar): its instances say what it holds.
#[component]
pub fn AccountPane() -> impl IntoView {
    let bought = LocalResource::new(|| async { crate::api::get::<crate::offers::Purchases>("/api/account/purchases").await });
    move || purchases_and_plan(bought.get())
}

fn purchases_and_plan(bought: Option<Result<crate::offers::Purchases, crate::api::Fail>>) -> AnyView {
    match bought {
        None => view! { <Loading/> }.into_any(),
        // The plan is ALWAYS drawn (owner 2026-10-17): an account with only
        // instances here is a free account, and its button says so.
        Some(Ok(p)) => view! {
            <Subscription/>
            <YourPurchases p=p/>
        }
        .into_any(),
        // No answer about purchases: the account's plan pane, as before.
        Some(Err(_)) => view! { <Subscription/> }.into_any(),
    }
}


/// The instances of [`AccountPane`], one row each; nothing when there are none.
#[component]
pub fn YourPurchases(p: crate::offers::Purchases) -> impl IntoView {
    (!p.estates.is_empty()).then(|| {
        view! {
            <Panel title="Your purchases".to_owned()>
                <div class="panel-body" id="your-purchases">
                    {p.estates.into_iter().map(|e| view! {
                        <div class="purchase instance-row" data-company=e.company.clone()>
                            <h3>{e.host.clone()}</h3>
                            <p>{[e.step.clone(), e.resources_words.clone()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" · ")}</p>
                            <p class="muted">{e.monthly_words.clone()}{(!e.paid_until.is_empty()).then(|| format!(" · paid until {}", e.paid_until))}</p>
                            <p class="muted">{e.words.clone()}</p>
                            <p class="row-actions">
                                <a class="btn primary" rel="external" href=e.grow_url.clone()>"Grow"</a>
                                {(!e.renew_url.is_empty()).then(|| view! { <a class="btn" rel="external" href=e.renew_url.clone()>{crate::offers::manage_words(&e.company)}</a> })}
                                {(!e.url.is_empty()).then(|| view! { <a class="btn" rel="external" href=e.url.clone()>"Open"</a> })}
                                {crate::offers::sign_in_again_offered(&e).then(|| view! { <SignInAgain company=e.company.clone()/> })}
                            </p>
                            // ★ ACCT-6: a repair is never an everyday button — its own small
                            // section, drawn only on monetize's word that the front needs one.
                            {crate::offers::restore_front_offered(&e).then(|| view! {
                                <div class="repair">
                                    <h4>"Repair"</h4>
                                    <p class="hint">{e.repair_words.clone()}</p>
                                    <RestoreFront company=e.company.clone()/>
                                </div>
                            })}
                        </div>
                    }).collect::<Vec<_>>()}
                </div>
            </Panel>
        }
    })
}

/// **"Restore front"** on an instance's row (S8): a lost front made again from its daily
/// backup, with its keys — monetize's `EstateOrder.RestoreFront` on the account's ticket. The
/// row shows monetize's words, or its refusal.
#[component]
fn RestoreFront(company: String) -> impl IntoView {
    let busy = RwSignal::new(false);
    let said = RwSignal::new(None::<Result<String, String>>);
    let ask = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        let company = company.clone();
        leptos::task::spawn_local(async move {
            let got = crate::api::post::<_, serde_json::Value>(crate::offers::RESTORE_FRONT, &serde_json::json!({ "company": company })).await;
            let words = match got {
                Ok(j) => Ok(j.get("words").and_then(|w| w.as_str()).filter(|w| !w.is_empty()).unwrap_or("Restored.").to_owned()),
                Err(f) => Err(if f.message.is_empty() { f.summary().to_owned() } else { f.message }),
            };
            let _ = said.try_set(Some(words));
            let _ = busy.try_set(false);
        });
    };
    view! {
        <button type="button" class="btn" disabled=move || busy.get() on:click=ask>"Restore front"</button>
        {move || said.get().map(|w| match w {
            Ok(w) => view! { <span class="muted restore-front">{w}</span> }.into_any(),
            Err(w) => view! { <span class="failure restore-front">{w}</span> }.into_any(),
        })}
    }
}

/// **"Send my sign-in link again"** on an instance's row (S10, OM-23): the ready mail's one
/// link lost or run out — a new one: monetize queues a signed order, the instance's main pulls
/// it, mints the link and mails it to the order's address itself.
/// The link never passes the console; the row shows the server's words, or its refusal.
#[component]
fn SignInAgain(company: String) -> impl IntoView {
    let busy = RwSignal::new(false);
    let said = RwSignal::new(None::<Result<String, String>>);
    let ask = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        let company = company.clone();
        leptos::task::spawn_local(async move {
            let got = crate::api::post::<_, serde_json::Value>(crate::offers::SIGN_IN_AGAIN, &serde_json::json!({ "company": company })).await;
            let words = match got {
                Ok(j) => Ok(j.get("words").and_then(|w| w.as_str()).filter(|w| !w.is_empty()).unwrap_or("Sent.").to_owned()),
                Err(f) => Err(if f.message.is_empty() { f.summary().to_owned() } else { f.message }),
            };
            let _ = said.try_set(Some(words));
            let _ = busy.try_set(false);
        });
    };
    view! {
        <button type="button" class="btn" disabled=move || busy.get() on:click=ask>"Send my sign-in link again"</button>
        {move || said.get().map(|w| match w {
            Ok(w) => view! { <span class="muted sign-in-again">{w}</span> }.into_any(),
            Err(w) => view! { <span class="failure sign-in-again">{w}</span> }.into_any(),
        })}
    }
}

/// **"N ledger steps"**: the fold's count on a past purchase, in the admin's
/// view only.
pub fn ledger_steps(n: u32) -> String {
    if n == 1 { "1 ledger step".to_owned() } else { format!("{n} ledger steps") }
}

/// **The pay button** — the ONE way to pay in this console, on both pages
/// that sell (this one and `increase.rs`). Big, the primary colour, the
/// vendor's own link in the same tab, and the price with "+ VAT" beside it.
/// Label and class are [`crate::purchase::PAY_LABEL`] / `PAY_CLASS`.
#[component]
pub fn PayButton(
    #[prop(into)] url: String,
    #[prop(into, optional)] price: String,
    /// "Pay with Stripe" (default) or "Resume payment".
    #[prop(optional)] label: Option<&'static str>,
) -> impl IntoView {
    let label = label.unwrap_or(crate::purchase::PAY_LABEL);
    view! {
        <div class="pay-row">
            <a class=crate::purchase::PAY_CLASS href=url>{label}</a>
            {(!price.is_empty()).then(|| view! { <span class="pay-price">{price}</span> })}
        </div>
    }
}

/// **Leave for the vendor**, in this tab. False when the browser would not.
pub(crate) fn navigate_to(url: &str) -> bool {
    web_sys::window().is_some_and(|w| w.location().assign(url).is_ok())
}

/// **"Resume payment"** for an order still open at the vendor, opened later:
/// asks the ledger for the order's live link and goes there, same tab. A real
/// primary button (`PAY_CLASS`). No link → says so, never a dead end.
#[component]
pub fn ResumeButton(#[prop(into)] reference: String) -> impl IntoView {
    let (said, set_said) = signal(None::<&'static str>);
    let (busy, set_busy) = signal(false);
    let resume = move |_| {
        let reference = reference.clone();
        set_busy.set(true);
        leptos::task::spawn_local(async move {
            let url = format!(
                "/api/account/subscription/status?reference={}",
                js_sys::encode_uri_component(&reference)
            );
            match crate::api::get::<Attempt>(&url).await {
                Ok(a) => {
                    let p = crate::purchase::phase_of(&a.phase, &a.state, a.captured);
                    match crate::purchase::go_to_vendor(p, &a.url) {
                        Some(url) if navigate_to(&url) => set_said.set(Some(crate::purchase::LEAVING)),
                        Some(_) | None if p == crate::purchase::Phase::Waiting => set_said.set(Some(crate::purchase::NO_LINK)),
                        _ => set_said.set(Some(crate::purchase::phase_detail(p))),
                    }
                }
                Err(_) => set_said.set(Some(crate::purchase::NO_LINK)),
            }
            set_busy.set(false);
        });
    };
    view! {
        <button class=crate::purchase::PAY_CLASS on:click=resume disabled=move || busy.get()>
            {crate::purchase::RESUME_LABEL}
        </button>
        {move || said.get().map(|s| view! { <p class="muted">{s}</p> })}
    }
}

/// **Follow an order until it is over**: re-reads it every few seconds while
/// it is waiting (also while its payment link is still being created) or
/// paid-and-adding, and draws it with [`OrderProgress`].
#[component]
pub fn FollowOrder(
    initial: Attempt,
    /// A just-placed order whose navigation did not happen ("Pay with
    /// Stripe"), as opposed to one opened later ("Resume payment").
    #[prop(optional)] fresh: bool,
    #[prop(into, optional)] price: String,
) -> impl IntoView {
    let (shown, set_shown) = signal(initial);
    let (asks, set_asks) = signal(0u32);
    // **A prolong's days left** (owner, 2026-10-05: "Prolonged until <date>",
    // the days left on a second line): monetize's own day count, off the
    // plan the window reports — read once the order is done, and drawn only
    // when that plan ends where this order's granted fact does. Silent on a
    // failure: the line is optional and the headline already holds the fact.
    let (days_left, set_days_left) = signal(None::<u32>);
    let asked_days = StoredValue::new(false);
    Effect::new(move |_| {
        let a = shown.get();
        let done = crate::purchase::phase_of(&a.phase, &a.state, a.captured) == crate::purchase::Phase::Done;
        if !done || crate::purchase::kind_of(&a.action) != crate::purchase::Kind::Prolong || asked_days.get_value() {
            return;
        }
        let Some(until) = a.paid_until_unix_ms else { return };
        asked_days.set_value(true);
        leptos::task::spawn_local(async move {
            if let Ok(shop) = crate::api::get::<crate::offers::Shop>("/api/account/subscription/bundles").await {
                if let Some(g) = shop.plan.filter(|g| g.paid_until_unix_ms == until) {
                    set_days_left.set(Some(g.days_left));
                }
            }
        });
    });
    Effect::new(move |_| {
        let a = shown.get();
        let over = crate::purchase::is_final(crate::purchase::phase_of(&a.phase, &a.state, a.captured));
        // A paid order whose room is still on its way is followed until the
        // server says it is done (the queued enlargement, owner 2026-10-04) —
        // not a prolong's, which adds no room and grows no disk.
        let shows_room = crate::purchase::shows_room(crate::purchase::kind_of(&a.action));
        let room_moving = (shows_room && a.room.as_ref().is_some_and(RoomLine::moving)) || (a.state == "paid" && a.room.is_none() && a.users.is_some());
        if (over && !room_moving) || a.reference.is_empty() {
            return;
        }
        leptos::task::spawn_local(async move {
            gloo_timers::future::TimeoutFuture::new(FOLLOW_MS).await;
            let url = format!(
                "/api/account/subscription/status?reference={}",
                js_sys::encode_uri_component(&a.reference)
            );
            if let Ok(mut next) = crate::api::get::<Attempt>(&url).await {
                // The poll carries no before/after of the order itself; keep
                // what the order was made against.
                if next.before.is_empty() {
                    next.before = a.before.clone();
                }
                if next.after.is_empty() {
                    next.after = a.after.clone();
                }
                // What the order does and its period, as the order itself
                // said them, when a poll did not.
                if next.action.is_empty() {
                    next.action = a.action.clone();
                }
                if next.period.is_empty() {
                    next.period = a.period.clone();
                }
                set_asks.update(|n| *n += 1);
                set_shown.set(next);
            }
        });
    });
    let price = StoredValue::new(price);
    move || view! {
        <OrderProgress
            attempt=shown.get()
            asks=asks.get()
            days_left=days_left.get().unwrap_or(0)
            fresh=fresh
            price=price.get_value()
            on_change=Callback::new(move |next| set_shown.set(next))
        />
    }
}

/// **One order, from the customer's side**: the three steps, where it is, the
/// current → new total, and the two things they can do while it waits — pay
/// with Stripe (the shared [`PayButton`]) or cancel. Every sentence is
/// [`crate::purchase`]'s; monetize's operator `reason` is never drawn.
#[component]
pub fn OrderProgress(
    attempt: Attempt,
    #[prop(optional)] asks: u32,
    #[prop(optional)] fresh: bool,
    #[prop(into, optional)] price: String,
    /// A prolong's days left, monetize's count (see [`FollowOrder`]); 0:
    /// not known, and no line.
    #[prop(optional)] days_left: u32,
    on_change: Callback<Attempt>,
) -> impl IntoView {
    use crate::purchase::{self, PayArea, Phase};

    let (cancelling, set_cancelling) = signal(false);
    let (fault, set_fault) = signal(None::<crate::api::Fail>);
    let p = purchase::phase_of(&attempt.phase, &attempt.state, attempt.captured);
    let cancellable = attempt.cancellable;
    let step = purchase::step_of(p);
    // What the order does to the plan (monetize's verb): a prolong is
    // "Prolonged until <date>" and never a sentence about room or disk.
    let kind = purchase::kind_of(&attempt.action);
    let now = purchase::now_holds(&attempt.after);
    let title = purchase::order_title(p, kind, now, &attempt.paid_until_words, &attempt.period);
    let detail = purchase::order_detail(p, kind, now, &attempt.paid_until_words);
    let days = purchase::days_left_line(days_left).filter(|_| p == Phase::Done && kind == purchase::Kind::Prolong);
    let room = attempt.room.clone().filter(|_| purchase::shows_room(kind));
    let change = match p {
        Phase::Waiting | Phase::PaidAdding => purchase::change_line(&attempt.before, &attempt.after),
        _ => None,
    };
    let pay = purchase::pay_area(p, &attempt.url, asks);
    let reference = attempt.reference.clone();
    let tone = match p {
        Phase::Done => Tone::Pass,
        Phase::PaidAdding => Tone::Live,
        Phase::Waiting => Tone::Warn,
        Phase::Cancelled | Phase::Unknown => Tone::Plain,
    };
    let cancel = move |_| {
        let reference = reference.clone();
        set_cancelling.set(true);
        set_fault.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<Which, Attempt>(
                "/api/account/subscription/cancel",
                &Which { reference },
            )
            .await
            {
                Ok(next) => on_change.run(next),
                Err(e) => set_fault.set(Some(e)),
            }
            set_cancelling.set(false);
        });
    };
    view! {
        <Panel title="Your order".to_owned()>
            <ol class="steps">
                {purchase::steps(kind).iter().enumerate().map(|(i, name)| {
                    let class = if i < step || (i == step && p == Phase::Done) {
                        "done"
                    } else if i == step {
                        "current"
                    } else {
                        ""
                    };
                    view! { <li class=class>{format!("{}. {name}", i + 1)}</li> }
                }).collect::<Vec<_>>()}
            </ol>
            <div class="row" id="order-title">
                <Badge tone=tone label=title />
            </div>
            {(!detail.is_empty()).then(|| view! { <p id="order-detail">{detail}</p> })}
            {days.map(|line| view! { <p class="muted" id="order-days-left">{line}</p> })}
            {change.map(|line| view! { <p><strong>{line}</strong></p> })}
            {attempt.users.clone().map(|u| {
                let gib = u.room_bytes >> 30;
                let said = match (u.granted, u.principal.is_empty()) {
                    _ if u.refunded => u.reason.clone(),
                    (true, false) if !u.invited.is_empty() => format!("{} is added, with {gib} GB of room. Their invite: {}.", u.principal, u.invited),
                    (true, false) => format!("{} is added, with {gib} GB of room.", u.principal),
                    (true, true) => format!("{} seat(s) added, with {gib} GB of room each.", u.seats),
                    (false, _) if u.bought_bytes > 0 => format!("The users are added once this is paid ({} GB more room is bought).", u.bought_bytes >> 30),
                    (false, _) => "The users are added once this is paid.".to_owned(),
                };
                view! { <p id="order-users" data-granted=u.granted.to_string() data-refunded=u.refunded.to_string()>{said}</p> }
            })}
            {room.map(|r| view! {
                <p id="room-progress" data-state=r.state.clone() aria-live="polite"><strong>{r.words.clone()}</strong></p>
            })}
            {match pay {
                PayArea::Button(url) => view! {
                    <PayButton url=url price=price.clone() label=crate::purchase::pay_label(fresh) />
                }.into_any(),
                PayArea::Creating => view! { <p class="muted" aria-live="polite">{purchase::CREATING_LINK}</p> }.into_any(),
                PayArea::Failed => view! { <p class="bad">{purchase::NO_LINK}</p> }.into_any(),
                PayArea::Nothing => ().into_any(),
            }}
            {cancellable.then(|| view! {
                <button class="btn" on:click=cancel.clone() disabled=move || cancelling.get()>
                    "Cancel order"
                </button>
            })}
            {move || fault.get().map(|fail| view! { <FailBox fail=fail /> })}
        </Panel>
    }
}

/// **The pay link** (UW-6): `/billing/{tenant}`, the pay link in every refusal, leads to the
/// tenant's renew page. `None` for any other path. The vendor's ways back, `/paid/{reference}`
/// and `/cancel/{reference}`, are the server's ONE order page (monetize-site-buy
/// `assets::paid_js`, T16, A28 M11), not a pane of the console's.
pub fn vendor_return() -> Option<AnyView> {
    let path = leptos_router::hooks::use_location().pathname.get_untracked();
    match crate::purchase::vendor_address(&path)? {
        crate::purchase::Address::PayPage => {
            let navigate = leptos_router::hooks::use_navigate();
            navigate(crate::purchase::PAY_PAGE, leptos_router::NavigateOptions { replace: true, ..Default::default() });
            Some(().into_any())
        }
    }
}