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